Compare commits

..

50 Commits

Author SHA1 Message Date
Charlie Voiselle eee13c42a4 docs(cli): reference --oidc-group-mapping flag name instead of 'legacy'
Issue: Used internal nomenclature instead of user-facing flag name

The previous fix referenced 'legacy group name mapping' but users don't
know what that means - it's an internal implementation detail. Users
configure this via the --oidc-group-mapping flag.

Changed to: 'This filter is applied after the oidc-group-mapping.'

This directly references the flag name users would actually use, making
the relationship clear and actionable. Users can now understand:
- The regex filter applies to group names
- Group names may have been transformed by --oidc-group-mapping first
- They need to write regex patterns that match the mapped names

Example: If --oidc-group-mapping transforms 'developers' to 'dev-team',
the regex in --oidc-group-regex-filter will match against 'dev-team'.
2026-02-09 16:14:00 -05:00
Charlie Voiselle 65b48c0f84 docs(cli): fix help text for --oidc-group-regex-filter (clarify mapping order)
Issue: Removed ordering information when it was actually helpful

The previous correction removed the sentence about filter order to avoid
confusion, but this actually made the description LESS clear. Users need
to understand that the regex filter operates on group names AFTER any
legacy name mapping has been applied.

Example: If IdP sends 'developers' and LegacyNameMapping renames it to
'dev-team', the regex filter will match against 'dev-team', not 'developers'.

Changed to: 'This filter is applied after legacy group name mapping.'

This clarifies:
1. It's the LEGACY mapping (name→name) not the new Mapping (name→IDs)
2. The regex operates on potentially-renamed group names
3. The filter happens before the final ID mapping

Code reference: coderd/idpsync/group.go lines 379-398
- Line 380: LegacyNameMapping (name → name)
- Line 386: RegexFilter (on the potentially renamed name)
- Line 392: Mapping (name → []uuid.UUID)
2026-02-09 16:13:59 -05:00
Charlie Voiselle 30cdf29e52 docs(cli): fix help text for --oidc-group-regex-filter (final correction)
Issue: Previous description incorrectly stated filter order

The correction commit stated 'This filter is applied after the group mapping'
but the actual code order in coderd/idpsync/group.go lines 379-398 shows:
1. Legacy group mappings
2. Regex filter
3. (New) group mapping

Since the filter order is complex and the description was causing confusion,
removed the last sentence entirely. The first two sentences clearly explain
what the flag does without introducing incorrect ordering claims.

This follows the verification report's recommendation to remove the
confusing last sentence.
2026-02-09 16:13:59 -05:00
Charlie Voiselle b1d2bb6d71 docs(cli): fix help text for --external-auth-providers
Issue: Clarity - vague description

Changed 'External Authentication providers.' to 'Configure external authentication providers for Git and other services.' to explain what these providers are actually used for.
2026-02-09 16:13:59 -05:00
Charlie Voiselle 94bad2a956 docs(cli): fix help text for --workspace-prebuilds-reconciliation-backoff-lookback-period
Issue: Clarity - unclear purpose

Changed 'Interval to look back to determine number of failed prebuilds, which influences backoff' to 'Time period to look back when counting failed prebuilds to calculate the backoff delay' to clarify this determines the time window for counting failures.
2026-02-09 16:13:59 -05:00
Charlie Voiselle 111714c7ed docs(cli): fix help text for --workspace-prebuilds-reconciliation-backoff-interval
Issue: Clarity - confusing wording about backoff behavior

Changed 'Interval to increase reconciliation backoff by when prebuilds fail, after which a retry attempt is made' to 'Amount of time to add to the reconciliation backoff delay after each prebuild failure, before the next retry attempt is made' to clarify this is an incremental addition to the backoff delay.
2026-02-09 16:13:58 -05:00
Charlie Voiselle 1f9c516c5c docs(cli): fix help text for --workspace-prebuilds-failure-hard-limit
Issue: Clarity - unclear what 'hits the hard limit' means

Changed 'before a preset hits the hard limit' to 'before a preset is considered hard-limited and stops automatic prebuild creation' to explain what actually happens when the limit is reached.
2026-02-09 16:13:58 -05:00
Charlie Voiselle 3645c65bb2 docs(cli): fix help text for --workspace-hostname-suffix
Issue: Clarity - incomplete example hostname

Changed 'in SSH config and Coder Connect on Coder Desktop' to 'for SSH connections and Coder Connect' for conciseness. Updated the example from 'myworkspace.coder' to the full format 'agent.workspace.owner.coder' to show the complete hostname structure.
2026-02-09 16:13:58 -05:00
Charlie Voiselle d3d2d2fb1e docs(cli): fix help text for --workspace-agent-logs-retention
Issue: Clarity - ambiguous scope

Changed 'Logs from the latest build are always retained' to 'Logs from the latest build for each workspace are always retained' to clarify that this applies per-workspace, not just one latest build globally.
2026-02-09 16:13:58 -05:00
Charlie Voiselle 086fb1f5d5 docs(cli): fix help text for --block-direct-connections
Issue: Clarity - imprecise wording about STUN behavior

Clarified that 'Workspace agents' (not 'Workspaces') reach out to STUN servers, changed 'get their address' to 'discover their address', and simplified 'until they are restarted after this change has been made' to just 'until they are restarted'.
2026-02-09 16:13:58 -05:00
Charlie Voiselle a73a535a5b docs(cli): fix help text for --proxy-health-interval
Issue: Clarity - awkward phrasing

Changed 'in which coderd should be checking' to 'at which coderd checks' for more concise, natural phrasing.
2026-02-09 16:13:57 -05:00
Charlie Voiselle 96e01c3018 docs(cli): fix help text for --email-tls-cert-key-file
Issue: Clarity - vague description

Changed 'Certificate key file to use' to 'Private key file for the client certificate' to clarify this is the private key that pairs with --email-tls-cert-file.
2026-02-09 16:13:57 -05:00
Charlie Voiselle 6b10a0359b docs(cli): fix help text for --email-tls-cert-file
Issue: Clarity - vague description

Changed 'Certificate file to use' to 'Client certificate file for mutual TLS authentication' to clarify what this certificate is for and when it's needed.
2026-02-09 16:13:57 -05:00
Charlie Voiselle b62583ad4b docs(cli): fix help text for --oidc-user-role-default
Issue: Clarity - ambiguous relationship between defaults and synced roles

Added 'in addition to synced roles' to clarify that these defaults don't replace synced roles. Also clarified that 'member' is always assigned 'regardless of this setting' to avoid confusion about whether this setting affects the member role.
2026-02-09 16:13:57 -05:00
Charlie Voiselle 3d6727a2cb docs(cli): fix help text for --oidc-group-field
Issue: Clarity - unclear structure

Reordered to put the primary purpose first: 'OIDC claim field to use as the user's groups' before the conditional requirement. This makes the description more scannable and understandable.
2026-02-09 16:13:56 -05:00
Charlie Voiselle b163962a14 docs(cli): fix help text for --aibridge-circuit-breaker-interval
Issue: Clarity - confusing technical jargon

Changed 'Cyclic period of the closed state for clearing internal failure counts' to 'Time window for counting failures before resetting the failure count in the closed state' to explain what the interval actually does in clearer terms.
2026-02-09 16:13:56 -05:00
Charlie Voiselle 9aca4ea27c docs(cli): fix help text for --aibridge-circuit-breaker-enabled
Issue: Clarity - ambiguous error code description

Changed '(429, 503, 529 overloaded)' to '(HTTP 429, 503, 529)' and added 'and overload errors' to clarify that these are HTTP status codes and what they represent.
2026-02-09 16:13:56 -05:00
Charlie Voiselle b0c10131ea docs(cli): fix help text for --aibridge-retention
Issue: Clarity - wordy phrasing

Simplified 'Length of time to retain data such as interceptions and all related records (token, prompt, tool use)' to 'How long to retain AI Bridge data including interceptions, tokens, prompts, and tool usage records' for more natural, clearer phrasing.
2026-02-09 16:13:56 -05:00
Charlie Voiselle c8c7e13e96 docs(cli): fix help text for --aibridge-inject-coder-mcp-tools
Issue: Clarity - awkward phrasing and formatting

Changed 'Whether to inject' to 'Enable injection of' for consistency with other boolean flags. Simplified the requirements clause and changed double quotes to single quotes for consistency.
2026-02-09 16:13:55 -05:00
Charlie Voiselle 249b7ea38e docs(cli): fix help text for --aibridge-enabled
Issue: Clarity - unclear technical jargon

Changed 'Whether to start an in-memory aibridged instance' to 'Enable the embedded AI Bridge service to intercept and record AI provider requests' to explain what the feature actually does in user-friendly terms.
2026-02-09 16:13:55 -05:00
Charlie Voiselle 1333096e25 docs(cli): fix help text for --oidc-group-regex-filter (correction)
Issue: Previous fix introduced confusing circular wording

The previous commit incorrectly changed the ending to 'after the group mapping and regex filter' which is nonsensical since this flag configures THE regex filter itself. Reverted to the correct wording: 'after the group mapping'.

The only valid changes from the original are:
- Added comma after 'If provided'
- Simplified 'allows for filtering' to 'allows filtering'
2026-02-09 16:13:55 -05:00
Charlie Voiselle 54bc9324dd docs(cli): fix help text for --samesite-auth-cookie
Issue: Grammar - missing word

Added missing 'if' to read 'Controls if the SameSite property is set' instead of 'Controls the SameSite property is set'.
2026-02-09 16:13:55 -05:00
Charlie Voiselle 109e5f2b19 docs(cli): fix help text for --enable-authz-recordings
Issue: Grammar - acronym capitalization

Capitalized 'API' (Application Programming Interface) - should always be uppercase.
2026-02-09 16:13:55 -05:00
Charlie Voiselle ee176b4207 docs(cli): fix help text for --ssh-config-options
Issue: Grammar - missing space after period

Added missing space after period between sentences: 'commas.' + 'Using' → 'commas. ' + 'Using'.
2026-02-09 16:13:54 -05:00
Charlie Voiselle 7e1e16be33 docs(cli): fix help text for --prometheus-address
Issue: Grammar - proper noun capitalization

Capitalized 'Prometheus' as it's a proper noun.
2026-02-09 16:13:54 -05:00
Charlie Voiselle 5cfe8082ce docs(cli): fix help text for --prometheus-enable
Issue: Grammar - proper noun capitalization

Capitalized 'Prometheus' as it's a proper noun (the name of the monitoring system).
2026-02-09 16:13:54 -05:00
Charlie Voiselle 6b7f672834 docs(cli): fix help text for --allow-custom-quiet-hours
Issue: Grammar - awkward phrasing

Changed 'for workspaces to stop in' to 'for when workspaces are stopped' for more natural phrasing.
2026-02-09 16:13:53 -05:00
Charlie Voiselle c55f6252a1 docs(cli): fix help text for --tls-client-ca-file
Issue: Grammar - missing article

Added missing article 'the' before 'client' to read 'authenticity of the client'.
2026-02-09 16:13:53 -05:00
Charlie Voiselle 842553b677 docs(cli): fix help text for --tls-ciphers
Issue: Grammar - missing verb

Fixed missing 'are' in 'that allowed to be used' → 'that are allowed to be used'.
2026-02-09 16:13:53 -05:00
Charlie Voiselle 05a771ba77 docs(cli): fix help text for --derp-server-stun-addresses
Issue: Grammar - incorrect possessive

Fixed "it's" (contraction of "it is") → "its" (possessive). Should be 'Each STUN server will get its own DERP region'.
2026-02-09 16:13:53 -05:00
Charlie Voiselle 70a0d42e65 docs(cli): fix help text for --derp-server-region-name
Issue: Grammar - malformed sentence

Fixed malformed sentence 'Region name that for' → 'Region name to use for'. The original was missing a verb.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 6b1d73b466 docs(cli): fix help text for --notifications-store-sync-buffer-size
Issue: Grammar - typo

Fixed typo: 'change' → 'chance'. Same typo as in --notifications-store-sync-interval.
2026-02-09 16:13:52 -05:00
Charlie Voiselle d7b9596145 docs(cli): fix help text for --notifications-store-sync-interval
Issue: Grammar - typo

Fixed typo: 'change' → 'chance'. The sentence should read 'the lower the chance of state inconsistency'.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 7a0aa1a40a docs(cli): fix help text for --oidc-signups-disabled-text
Issue: Grammar - awkward phrasing

Changed 'The custom text to show on the error page informing about disabled OIDC signups' to 'Custom text to show on the error page when OIDC signups are disabled' for clearer, more direct phrasing. Removed unnecessary 'The' article.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 4d8ea43e11 docs(cli): fix help text for --oidc-icon-url
Issue: Grammar - redundant phrasing

Changed 'URL pointing to the icon' to 'URL of the icon'. The phrase 'pointing to' is redundant since a URL inherently points to a resource.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 6fddae98f6 docs(cli): fix help text for --oidc-group-regex-filter
Issue: Grammar - missing comma + simplification + filter order clarification

Added missing comma after 'If provided'. Simplified 'allows for filtering' to 'allows filtering'. Clarified filter order to match the actual implementation.
2026-02-09 16:13:51 -05:00
Charlie Voiselle e33fbb6087 docs(cli): fix help text for --oidc-group-mapping
Issue: Grammar - subject-verb agreement + awkward phrasing

Changed 'the group in Coder it should map to' to 'the groups in Coder they should map to' for proper plural agreement. Also simplified 'for when' to 'when'.
2026-02-09 16:13:51 -05:00
Charlie Voiselle 2337393e13 docs(cli): fix help text for --oidc-client-cert-file
Issue: Grammar - incorrect acronym capitalization

Changed 'Pem' to 'PEM', 'oauth2' to 'OAuth2', and 'x509' to 'X.509'. These are standard capitalizations for these acronyms and standards.
2026-02-09 16:13:51 -05:00
Charlie Voiselle d7357a1b0a docs(cli): fix help text for --oidc-client-key-file
Issue: Grammar - incorrect acronym capitalization

Changed 'Pem' to 'PEM' (Privacy Enhanced Mail), 'oauth2' to 'OAuth2', and 'IDP' to 'IdP' (Identity Provider). These are standard capitalizations for these acronyms.
2026-02-09 16:13:51 -05:00
Charlie Voiselle afbf1af29c docs(cli): fix help text for --oauth2-github-allow-everyone
Issue: Grammar - unclear and run-on sentence

Changed 'Allow all logins, setting this option means...' to 'Allow all GitHub users to authenticate. When enabled, allowed orgs and teams must be empty.' This separates the run-on sentence and clarifies what 'all logins' means (all GitHub users).
2026-02-09 16:13:50 -05:00
Charlie Voiselle 1d834c747c docs(cli): fix help text for --aibridge-circuit-breaker-failure-threshold
Issue: Grammar - subject-verb agreement

Changed 'triggers' to 'trigger' for correct subject-verb agreement. 'Number' is the subject, which takes the singular form, but 'failures' is the head of the relative clause 'that trigger...', making 'trigger' (plural) correct.
2026-02-09 16:13:50 -05:00
Charlie Voiselle a80edec752 docs(cli): fix help text for --aibridge-bedrock-access-key-secret
Issue: Grammar - wordy and redundant phrasing

Simplified from 'The access key secret to use with the access key to authenticate against' to 'AWS secret access key for authenticating with'. Uses standard AWS terminology and eliminates redundancy.
2026-02-09 16:13:50 -05:00
Charlie Voiselle 2a6473e8c6 docs(cli): fix help text for --aibridge-bedrock-access-key
Issue: Grammar - awkward phrasing

Changed 'The access key to authenticate against' to 'AWS access key for authenticating with' for consistency and clarity. Uses standard AWS terminology.
2026-02-09 16:13:50 -05:00
Charlie Voiselle 1f9c0b9b7f docs(cli): fix help text for --aibridge-anthropic-key
Issue: Grammar - awkward phrasing

Changed 'The key to authenticate against' to 'API key for authenticating with' for consistency with --aibridge-openai-key and more natural phrasing.
2026-02-09 16:13:49 -05:00
Charlie Voiselle 5494afabd8 docs(cli): fix help text for --aibridge-openai-key
Issue: Grammar - awkward phrasing

Changed 'The key to authenticate against' to 'API key for authenticating with' for more natural, concise phrasing. This matches standard API documentation conventions.
2026-02-09 16:13:49 -05:00
Charlie Voiselle 07c6e86a50 docs(cli): fix help text for --notifications-email-hello
Issue: Factually incorrect description of SMTP HELO/EHLO (deprecated alias)

Same issue as --email-hello. This is a deprecated alias but still needs the correct description. The HELO/EHLO command identifies the client to the server, not the server itself.

Fix: Clarified this identifies 'this client to the SMTP server'.
2026-02-09 16:13:49 -05:00
Charlie Voiselle b543821a1c docs(cli): fix help text for --email-hello
Issue: Factually incorrect description of SMTP HELO/EHLO

The description incorrectly stated this identifies 'the SMTP server' when it actually identifies the CLIENT to the server. The HELO/EHLO command is how the client introduces itself to the SMTP server during connection.

Fix: Clarified this identifies 'this client to the SMTP server' which accurately reflects the SMTP protocol.
2026-02-09 16:13:49 -05:00
Charlie Voiselle e8b7045a9b docs(cli): fix help text for --pprof-enable
Issue: Factually incorrect terminology

The description incorrectly stated pprof serves 'metrics' when it actually serves profiling data (CPU profiles, memory profiles, goroutines, etc.). Metrics are Prometheus's domain, not pprof's.

Fix: Changed 'metrics' to 'profiling endpoints' to accurately describe what pprof provides.
2026-02-09 16:13:48 -05:00
Charlie Voiselle 2571089528 docs(cli): fix help text for --oidc-user-role-mapping
Issue: Factually incorrect (confuses roles with groups) + grammar error

The description incorrectly stated this maps to 'groups in Coder' when it actually maps to site ROLES (member, admin, etc.). Also had a grammar error: 'will ignored' should be 'will be ignored'.

Fix: Corrected to clarify this maps OIDC role names to Coder role names, and fixed the grammar error.
2026-02-09 16:13:48 -05:00
Charlie Voiselle 1fb733fe1e docs(cli): fix help text for --oidc-allowed-groups
Issue: Factually incorrect filter order

The description incorrectly stated that the check is applied 'after the group mapping and before the regex filter'. This is wrong.

Fix: Updated to reflect actual behavior where the check is applied BEFORE any group mapping or filtering. Also clarified the positive case (users WITH at least one matching group are allowed) instead of the confusing double-negative phrasing.
2026-02-09 16:13:48 -05:00
57 changed files with 1096 additions and 1400 deletions
-370
View File
@@ -1,370 +0,0 @@
name: Deploy Branch
on:
push:
workflow_dispatch:
inputs:
deploy_only:
description: "Skip build and only run deploy (debug-only)."
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: true
jobs:
build:
if: ${{ github.event_name != 'workflow_dispatch' || !inputs.deploy_only }}
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
permissions:
packages: write
env:
CODER_IMAGE_TAG: "ghcr.io/coder/coder-preview:pr${{ github.ref_name }}"
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: GHCR Login
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
run: |
set -euo pipefail
go mod download
make gen/mark-fresh
export DOCKER_IMAGE_NO_PREREQUISITES=true
version="$(./scripts/version.sh)"
CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")"
export CODER_IMAGE_BUILD_BASE_TAG
make -j build/coder_linux_amd64
./scripts/build_docker.sh \
--arch amd64 \
--target "${CODER_IMAGE_TAG}" \
--version "$version" \
--push \
build/coder_linux_amd64
deploy:
needs: build
if: ${{ always() && (needs.build.result == 'success' || (github.event_name == 'workflow_dispatch' && inputs.deploy_only && needs.build.result == 'skipped')) }}
runs-on: ubuntu-latest
env:
BRANCH_NAME: ${{ github.ref_name }}
DEPLOY_NAME: "pr${{ github.ref_name }}"
TEST_DOMAIN_SUFFIX: "${{ startsWith(secrets.PR_DEPLOYMENTS_DOMAIN, 'test.') && secrets.PR_DEPLOYMENTS_DOMAIN || format('test.{0}', secrets.PR_DEPLOYMENTS_DOMAIN) }}"
BRANCH_HOSTNAME: "${{ github.ref_name }}.${{ startsWith(secrets.PR_DEPLOYMENTS_DOMAIN, 'test.') && secrets.PR_DEPLOYMENTS_DOMAIN || format('test.{0}', secrets.PR_DEPLOYMENTS_DOMAIN) }}"
CODER_IMAGE_TAG: "ghcr.io/coder/coder-preview:pr${{ github.ref_name }}"
REPO: ghcr.io/coder/coder-preview
EXPERIMENTS: "*"
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up kubeconfig
run: |
set -euo pipefail
mkdir -p ~/.kube
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
- name: Verify cluster authentication
run: |
set -euo pipefail
kubectl auth can-i get namespaces > /dev/null
- name: Check if deployment exists
id: check
run: |
set -euo pipefail
set +e
helm_status_output="$(helm status "${DEPLOY_NAME}" --namespace "${DEPLOY_NAME}" 2>&1)"
helm_status_code=$?
set -e
if [ "$helm_status_code" -eq 0 ]; then
echo "new=false" >> "$GITHUB_OUTPUT"
elif echo "$helm_status_output" | grep -qi "release: not found"; then
echo "new=true" >> "$GITHUB_OUTPUT"
else
echo "$helm_status_output"
exit "$helm_status_code"
fi
# ---- Every push: ensure routing + TLS ----
- name: Ensure DNS records
run: |
set -euo pipefail
api_base_url="https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records"
base_name="${BRANCH_HOSTNAME}"
base_target="${TEST_DOMAIN_SUFFIX}"
wildcard_name="*.${BRANCH_HOSTNAME}"
ensure_cname_record() {
local record_name="$1"
local record_content="$2"
echo "Ensuring CNAME ${record_name} -> ${record_content}."
set +e
lookup_raw_response="$(
curl -sS -G "${api_base_url}" \
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type:application/json" \
--data-urlencode "name=${record_name}" \
--data-urlencode "per_page=100" \
-w '\n%{http_code}'
)"
lookup_exit_code=$?
set -e
if [ "$lookup_exit_code" -eq 0 ]; then
lookup_response="${lookup_raw_response%$'\n'*}"
lookup_http_code="${lookup_raw_response##*$'\n'}"
if [ "$lookup_http_code" = "200" ] && echo "$lookup_response" | jq -e '.success == true' > /dev/null 2>&1; then
if echo "$lookup_response" | jq -e '.result[]? | select(.type != "CNAME")' > /dev/null 2>&1; then
echo "Conflicting non-CNAME DNS record exists for ${record_name}."
echo "$lookup_response"
return 1
fi
existing_cname_id="$(echo "$lookup_response" | jq -r '.result[]? | select(.type == "CNAME") | .id' | head -n1)"
if [ -n "$existing_cname_id" ]; then
existing_content="$(echo "$lookup_response" | jq -r --arg id "$existing_cname_id" '.result[] | select(.id == $id) | .content')"
if [ "$existing_content" = "$record_content" ]; then
echo "CNAME already set for ${record_name}."
return 0
fi
echo "Updating existing CNAME for ${record_name}."
update_response="$(
curl -sS -X PUT "${api_base_url}/${existing_cname_id}" \
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type:application/json" \
--data '{"type":"CNAME","name":"'"${record_name}"'","content":"'"${record_content}"'","ttl":1,"proxied":false}'
)"
if echo "$update_response" | jq -e '.success == true' > /dev/null 2>&1; then
echo "Updated CNAME for ${record_name}."
return 0
fi
echo "Cloudflare API error while updating ${record_name}:"
echo "$update_response"
return 1
fi
fi
else
echo "Could not query DNS record ${record_name}; attempting create."
fi
max_attempts=6
attempt=1
last_response=""
last_http_code=""
while [ "$attempt" -le "$max_attempts" ]; do
echo "Creating DNS record ${record_name} (attempt ${attempt}/${max_attempts})."
set +e
raw_response="$(
curl -sS -X POST "${api_base_url}" \
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type:application/json" \
--data '{"type":"CNAME","name":"'"${record_name}"'","content":"'"${record_content}"'","ttl":1,"proxied":false}' \
-w '\n%{http_code}'
)"
curl_exit_code=$?
set -e
curl_failed=false
if [ "$curl_exit_code" -eq 0 ]; then
response="${raw_response%$'\n'*}"
http_code="${raw_response##*$'\n'}"
else
response="curl exited with code ${curl_exit_code}."
http_code="000"
curl_failed=true
fi
last_response="$response"
last_http_code="$http_code"
if echo "$response" | jq -e '.success == true' > /dev/null 2>&1; then
echo "Created DNS record ${record_name}."
return 0
fi
# 81057: identical record exists. 81053: host record conflict.
if echo "$response" | jq -e '.errors[]? | select(.code == 81057 or .code == 81053)' > /dev/null 2>&1; then
echo "DNS record already exists for ${record_name}."
return 0
fi
transient_error=false
if [ "$curl_failed" = true ] || [ "$http_code" = "429" ]; then
transient_error=true
elif [[ "$http_code" =~ ^[0-9]{3}$ ]] && [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; then
transient_error=true
fi
if echo "$response" | jq -e '.errors[]? | select(.code == 10000 or .code == 10001)' > /dev/null 2>&1; then
transient_error=true
fi
if [ "$transient_error" = true ] && [ "$attempt" -lt "$max_attempts" ]; then
sleep_seconds=$((attempt * 5))
echo "Transient Cloudflare API error (HTTP ${http_code}). Retrying in ${sleep_seconds}s."
sleep "$sleep_seconds"
attempt=$((attempt + 1))
continue
fi
break
done
echo "Cloudflare API error while creating DNS record ${record_name} after ${attempt} attempt(s):"
echo "HTTP status: ${last_http_code}"
echo "$last_response"
return 1
}
ensure_cname_record "${base_name}" "${base_target}"
ensure_cname_record "${wildcard_name}" "${base_name}"
# ---- First deploy only ----
- name: Create namespace
if: steps.check.outputs.new == 'true'
run: |
set -euo pipefail
kubectl delete namespace "${DEPLOY_NAME}" || true
kubectl create namespace "${DEPLOY_NAME}"
# ---- Every push: ensure deployment certificate ----
- name: Ensure certificate
env:
PR_NUMBER: ${{ env.BRANCH_NAME }}
PR_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
run: |
set -euo pipefail
cert_secret_name="${DEPLOY_NAME}-tls"
envsubst < ./.github/pr-deployments/certificate.yaml | kubectl apply -f -
if ! kubectl -n pr-deployment-certs wait --for=condition=Ready "certificate/${cert_secret_name}" --timeout=10m; then
echo "Timed out waiting for certificate ${cert_secret_name} to become Ready after 10 minutes."
kubectl -n pr-deployment-certs describe certificate "${cert_secret_name}" || true
kubectl -n pr-deployment-certs get certificaterequest,order,challenge -l "cert-manager.io/certificate-name=${cert_secret_name}" || true
exit 1
fi
kubectl get secret "${cert_secret_name}" -n pr-deployment-certs -o json |
jq 'del(.metadata.namespace,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.selfLink,.metadata.uid,.metadata.managedFields)' |
kubectl -n "${DEPLOY_NAME}" apply -f -
- name: Set up PostgreSQL
if: steps.check.outputs.new == 'true'
run: |
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install coder-db bitnami/postgresql \
--namespace "${DEPLOY_NAME}" \
--set image.repository=bitnamilegacy/postgresql \
--set auth.username=coder \
--set auth.password=coder \
--set auth.database=coder \
--set persistence.size=10Gi
kubectl create secret generic coder-db-url -n "${DEPLOY_NAME}" \
--from-literal=url="postgres://coder:coder@coder-db-postgresql.${DEPLOY_NAME}.svc.cluster.local:5432/coder?sslmode=disable"
- name: Create RBAC
if: steps.check.outputs.new == 'true'
env:
PR_NUMBER: ${{ env.BRANCH_NAME }}
PR_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
run: envsubst < ./.github/pr-deployments/rbac.yaml | kubectl apply -f -
# ---- Every push ----
- name: Create values.yaml
env:
PR_NUMBER: ${{ env.BRANCH_NAME }}
PR_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
REPO: ${{ env.REPO }}
PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID }}
PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET }}
run: envsubst < ./.github/pr-deployments/values.yaml > ./deploy-values.yaml
- name: Install/Upgrade Helm chart
run: |
set -euo pipefail
helm dependency update --skip-refresh ./helm/coder
helm upgrade --install "${DEPLOY_NAME}" ./helm/coder \
--namespace "${DEPLOY_NAME}" \
--values ./deploy-values.yaml \
--force
- name: Install coder-logstream-kube
if: steps.check.outputs.new == 'true'
run: |
helm repo add coder-logstream-kube https://helm.coder.com/logstream-kube
helm upgrade --install coder-logstream-kube coder-logstream-kube/coder-logstream-kube \
--namespace "${DEPLOY_NAME}" \
--set url="https://${BRANCH_HOSTNAME}"
- name: Create first user and template
if: steps.check.outputs.new == 'true'
run: |
set -euo pipefail
URL="https://${BRANCH_HOSTNAME}/bin/coder-linux-amd64"
COUNT=0
until curl --output /dev/null --silent --head --fail "$URL"; do
sleep 5
COUNT=$((COUNT+1))
if [ "$COUNT" -ge 60 ]; then echo "Timed out"; exit 1; fi
done
curl -fsSL "$URL" -o /tmp/coder && chmod +x /tmp/coder
password=$(openssl rand -base64 16 | tr -d "=+/" | cut -c1-12)
echo "::add-mask::$password"
/tmp/coder login \
--first-user-username "${BRANCH_NAME}-admin" \
--first-user-email "${BRANCH_NAME}@coder.com" \
--first-user-password "$password" \
--first-user-trial=false \
--use-token-as-session \
"https://${BRANCH_HOSTNAME}"
cd .github/pr-deployments/template
/tmp/coder templates push -y --variable "namespace=${DEPLOY_NAME}" kubernetes
/tmp/coder create --template="kubernetes" kube \
--parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y
/tmp/coder stop kube -y
-2
View File
@@ -17,6 +17,4 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
CheckGroupAclIsObject CheckConstraint = "group_acl_is_object" // workspaces
CheckUserAclIsObject CheckConstraint = "user_acl_is_object" // workspaces
)
+5 -2
View File
@@ -19,6 +19,7 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
@@ -29,6 +30,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/coderd/taskname"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/provisionerd/proto"
@@ -1662,12 +1664,13 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas
parameters = json.RawMessage([]byte("{}"))
}
taskName := taskname.Generate(genCtx, slog.Make(), orig.Prompt)
task, err := db.InsertTask(genCtx, database.InsertTaskParams{
ID: takeFirst(orig.ID, uuid.New()),
OrganizationID: orig.OrganizationID,
OwnerID: orig.OwnerID,
Name: takeFirst(orig.Name, testutil.GetRandomNameHyphenated(t)),
DisplayName: takeFirst(orig.DisplayName, testutil.GetRandomNameHyphenated(t)),
Name: takeFirst(orig.Name, taskName.Name),
DisplayName: takeFirst(orig.DisplayName, taskName.DisplayName),
WorkspaceID: orig.WorkspaceID,
TemplateVersionID: orig.TemplateVersionID,
TemplateParameters: parameters,
+1 -3
View File
@@ -2736,9 +2736,7 @@ CREATE TABLE workspaces (
favorite boolean DEFAULT false NOT NULL,
next_start_at timestamp with time zone,
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT group_acl_is_object CHECK ((jsonb_typeof(group_acl) = 'object'::text)),
CONSTRAINT user_acl_is_object CHECK ((jsonb_typeof(user_acl) = 'object'::text))
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL
);
COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.';
@@ -1,3 +0,0 @@
ALTER TABLE workspaces
DROP CONSTRAINT IF EXISTS group_acl_is_object,
DROP CONSTRAINT IF EXISTS user_acl_is_object;
@@ -1,9 +0,0 @@
-- Add constraints that reject 'null'::jsonb for group and user ACLs
-- because they would break the new workspace_expanded view.
UPDATE workspaces SET group_acl = '{}'::jsonb WHERE group_acl = 'null'::jsonb;
UPDATE workspaces SET user_acl = '{}'::jsonb WHERE user_acl = 'null'::jsonb;
ALTER TABLE workspaces
ADD CONSTRAINT group_acl_is_object CHECK (jsonb_typeof(group_acl) = 'object'),
ADD CONSTRAINT user_acl_is_object CHECK (jsonb_typeof(user_acl) = 'object');
@@ -1,35 +0,0 @@
-- Fixture for migration 000417_workspace_acl_object_constraint.
-- Inserts a workspace with 'null'::json ACLs to ensure the migration
-- correctly normalizes such values.
INSERT INTO workspaces (
id,
created_at,
updated_at,
owner_id,
organization_id,
template_id,
deleted,
name,
last_used_at,
automatic_updates,
favorite,
group_acl,
user_acl
)
VALUES (
'6f6fdbee-4c18-4a5c-8a8d-9b811c9f0a28',
'2024-02-10 00:00:00+00',
'2024-02-10 00:00:00+00',
'30095c71-380b-457a-8995-97b8ee6e5307',
'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1',
'4cc1f466-f326-477e-8762-9d0c6781fc56',
false,
'acl-null-workspace',
'0001-01-01 00:00:00+00',
'never',
false,
'null'::jsonb,
'null'::jsonb
)
ON CONFLICT DO NOTHING;
-59
View File
@@ -6765,65 +6765,6 @@ func TestWorkspaceBuildDeadlineConstraint(t *testing.T) {
}
}
func TestWorkspaceACLObjectConstraint(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
template := dbgen.Template(t, db, database.Template{
CreatedBy: user.ID,
OrganizationID: org.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
TemplateID: template.ID,
Deleted: false,
})
t.Run("GroupACLNull", func(t *testing.T) {
t.Parallel()
var nilACL database.WorkspaceACL
ctx := testutil.Context(t, testutil.WaitLong)
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
GroupACL: nilACL,
UserACL: database.WorkspaceACL{},
})
require.Error(t, err)
require.True(t, database.IsCheckViolation(err, database.CheckGroupAclIsObject))
})
t.Run("UserACLNull", func(t *testing.T) {
t.Parallel()
var nilACL database.WorkspaceACL
ctx := testutil.Context(t, testutil.WaitLong)
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
GroupACL: database.WorkspaceACL{},
UserACL: nilACL,
})
require.Error(t, err)
require.True(t, database.IsCheckViolation(err, database.CheckUserAclIsObject))
})
t.Run("ValidEmptyObjects", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
ID: workspace.ID,
GroupACL: database.WorkspaceACL{},
UserACL: database.WorkspaceACL{},
})
require.NoError(t, err)
})
}
// TestGetLatestWorkspaceBuildsByWorkspaceIDs populates the database with
// workspaces and builds. It then tests that
// GetLatestWorkspaceBuildsByWorkspaceIDs returns the latest build for some
+48 -48
View File
@@ -1431,7 +1431,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
}
emailHello := serpent.Option{
Name: "Email: Hello",
Description: "The hostname identifying the SMTP server.",
Description: "The hostname identifying this client to the SMTP server.",
Flag: "email-hello",
Env: "CODER_EMAIL_HELLO",
Default: "localhost",
@@ -1523,7 +1523,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
}
emailTLSCertFile := serpent.Option{
Name: "Email TLS: Certificate File",
Description: "Certificate file to use.",
Description: "Client certificate file for mutual TLS authentication.",
Flag: "email-tls-cert-file",
Env: "CODER_EMAIL_TLS_CERTFILE",
Value: &c.Notifications.SMTP.TLS.CertFile,
@@ -1532,7 +1532,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
}
emailTLSCertKeyFile := serpent.Option{
Name: "Email TLS: Certificate Key File",
Description: "Certificate key file to use.",
Description: "Private key file for the client certificate.",
Flag: "email-tls-cert-key-file",
Env: "CODER_EMAIL_TLS_CERTKEYFILE",
Value: &c.Notifications.SMTP.TLS.KeyFile,
@@ -1551,7 +1551,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
}
workspaceHostnameSuffix := serpent.Option{
Name: "Workspace Hostname Suffix",
Description: "Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like myworkspace.coder.",
Description: "Workspace hostnames use this suffix for SSH connections and Coder Connect. By default it is coder, resulting in hostnames like agent.workspace.owner.coder.",
Flag: "workspace-hostname-suffix",
Env: "CODER_WORKSPACE_HOSTNAME_SUFFIX",
YAML: "workspaceHostnameSuffix",
@@ -1680,7 +1680,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "TLS Client CA Files",
Description: "PEM-encoded Certificate Authority file used for checking the authenticity of client.",
Description: "PEM-encoded Certificate Authority file used for checking the authenticity of the client.",
Flag: "tls-client-ca-file",
Env: "CODER_TLS_CLIENT_CA_FILE",
Value: &c.TLS.ClientCAFile,
@@ -1742,7 +1742,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "TLS Ciphers",
Description: "Specify specific TLS ciphers that allowed to be used. See https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L53-L75.",
Description: "Specify specific TLS ciphers that are allowed to be used. See https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L53-L75.",
Flag: "tls-ciphers",
Env: "CODER_TLS_CIPHERS",
Default: "",
@@ -1800,7 +1800,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "DERP Server Region Name",
Description: "Region name that for the embedded DERP server.",
Description: "Region name to use for the embedded DERP server.",
Flag: "derp-server-region-name",
Env: "CODER_DERP_SERVER_REGION_NAME",
Default: "Coder Embedded Relay",
@@ -1811,7 +1811,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "DERP Server STUN Addresses",
Description: "Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get it's own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely.",
Description: "Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get its own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely.",
Flag: "derp-server-stun-addresses",
Env: "CODER_DERP_SERVER_STUN_ADDRESSES",
Default: "stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302",
@@ -1833,7 +1833,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "Block Direct Connections",
Description: "Block peer-to-peer (aka. direct) workspace connections. All workspace connections from the CLI will be proxied through Coder (or custom configured DERP servers) and will never be peer-to-peer when enabled. Workspaces may still reach out to STUN servers to get their address until they are restarted after this change has been made, but new connections will still be proxied regardless.",
Description: "Block peer-to-peer (aka. direct) workspace connections. All workspace connections from the CLI will be proxied through Coder (or custom configured DERP servers) and will never be peer-to-peer when enabled. Workspace agents may still reach out to STUN servers to discover their address until they are restarted, but all new connections will be proxied regardless.",
// This cannot be called `disable-direct-connections` because that's
// already a global CLI flag for CLI connections. This is a
// deployment-wide flag.
@@ -1884,7 +1884,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
// Prometheus settings
{
Name: "Prometheus Enable",
Description: "Serve prometheus metrics on the address defined by prometheus address.",
Description: "Serve Prometheus metrics on the address defined by prometheus address.",
Flag: "prometheus-enable",
Env: "CODER_PROMETHEUS_ENABLE",
Value: &c.Prometheus.Enable,
@@ -1894,7 +1894,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "Prometheus Address",
Description: "The bind address to serve prometheus metrics.",
Description: "The bind address to serve Prometheus metrics.",
Flag: "prometheus-address",
Env: "CODER_PROMETHEUS_ADDRESS",
Default: "127.0.0.1:2112",
@@ -1945,7 +1945,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
// Pprof settings
{
Name: "pprof Enable",
Description: "Serve pprof metrics on the address defined by pprof address.",
Description: "Serve pprof profiling endpoints on the address defined by pprof address.",
Flag: "pprof-enable",
Env: "CODER_PPROF_ENABLE",
Value: &c.Pprof.Enable,
@@ -2032,7 +2032,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OAuth2 GitHub Allow Everyone",
Description: "Allow all logins, setting this option means allowed orgs and teams must be empty.",
Description: "Allow all GitHub users to authenticate. When enabled, allowed orgs and teams must be empty.",
Flag: "oauth2-github-allow-everyone",
Env: "CODER_OAUTH2_GITHUB_ALLOW_EVERYONE",
Value: &c.OAuth2.Github.AllowEveryone,
@@ -2079,8 +2079,8 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Client Key File",
Description: "Pem encoded RSA private key to use for oauth2 PKI/JWT authorization. " +
"This can be used instead of oidc-client-secret if your IDP supports it.",
Description: "PEM encoded RSA private key to use for OAuth2 PKI/JWT authorization. " +
"This can be used instead of oidc-client-secret if your IdP supports it.",
Flag: "oidc-client-key-file",
Env: "CODER_OIDC_CLIENT_KEY_FILE",
YAML: "oidcClientKeyFile",
@@ -2089,8 +2089,8 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Client Cert File",
Description: "Pem encoded certificate file to use for oauth2 PKI/JWT authorization. " +
"The public certificate that accompanies oidc-client-key-file. A standard x509 certificate is expected.",
Description: "PEM encoded certificate file to use for OAuth2 PKI/JWT authorization. " +
"The public certificate that accompanies oidc-client-key-file. A standard X.509 certificate is expected.",
Flag: "oidc-client-cert-file",
Env: "CODER_OIDC_CLIENT_CERT_FILE",
YAML: "oidcClientCertFile",
@@ -2242,7 +2242,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Group Field",
Description: "This field must be set if using the group sync feature and the scope name is not 'groups'. Set to the claim to be used for groups.",
Description: "OIDC claim field to use as the user's groups. This field must be set if using the group sync feature and the scope name is not 'groups'.",
Flag: "oidc-group-field",
Env: "CODER_OIDC_GROUP_FIELD",
// This value is intentionally blank. If this is empty, then OIDC group
@@ -2257,7 +2257,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Group Mapping",
Description: "A map of OIDC group IDs and the group in Coder it should map to. This is useful for when OIDC providers only return group IDs.",
Description: "A map of OIDC group IDs and the groups in Coder they should map to. This is useful when OIDC providers only return group IDs.",
Flag: "oidc-group-mapping",
Env: "CODER_OIDC_GROUP_MAPPING",
Default: "{}",
@@ -2277,7 +2277,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Regex Group Filter",
Description: "If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is applied after the group mapping.",
Description: "If provided, any group name not matching the regex is ignored. This allows filtering out groups that are not needed. This filter is applied after the OIDC Group Mapping step.",
Flag: "oidc-group-regex-filter",
Env: "CODER_OIDC_GROUP_REGEX_FILTER",
Default: ".*",
@@ -2287,7 +2287,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC Allowed Groups",
Description: "If provided any group name not in the list will not be allowed to authenticate. This allows for restricting access to a specific set of groups. This filter is applied after the group mapping and before the regex filter.",
Description: "If provided, only users with at least one group in this list will be allowed to authenticate. This restricts access to a specific set of groups. This check is applied before any group mapping or filtering.",
Flag: "oidc-allowed-groups",
Env: "CODER_OIDC_ALLOWED_GROUPS",
Default: "",
@@ -2309,7 +2309,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC User Role Mapping",
Description: "A map of the OIDC passed in user roles and the groups in Coder it should map to. This is useful if the group names do not match. If mapped to the empty string, the role will ignored.",
Description: "A map of OIDC user role names to Coder role names. This is useful if the role names do not match between systems. If mapped to the empty string, the role will be ignored.",
Flag: "oidc-user-role-mapping",
Env: "CODER_OIDC_USER_ROLE_MAPPING",
Default: "{}",
@@ -2319,7 +2319,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OIDC User Role Default",
Description: "If user role sync is enabled, these roles are always included for all authenticated users. The 'member' role is always assigned.",
Description: "If user role sync is enabled, these roles are always included for all authenticated users in addition to synced roles. The 'member' role is always assigned regardless of this setting.",
Flag: "oidc-user-role-default",
Env: "CODER_OIDC_USER_ROLE_DEFAULT",
Default: "",
@@ -2339,7 +2339,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "OpenID connect icon URL",
Description: "URL pointing to the icon to use on the OpenID Connect login button.",
Description: "URL of the icon to use on the OpenID Connect login button.",
Flag: "oidc-icon-url",
Env: "CODER_OIDC_ICON_URL",
Value: &c.OIDC.IconURL,
@@ -2348,7 +2348,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "Signups disabled text",
Description: "The custom text to show on the error page informing about disabled OIDC signups. Markdown format is supported.",
Description: "Custom text to show on the error page when OIDC signups are disabled. Markdown format is supported.",
Flag: "oidc-signups-disabled-text",
Env: "CODER_OIDC_SIGNUPS_DISABLED_TEXT",
Value: &c.OIDC.SignupsDisabledText,
@@ -2807,7 +2807,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
},
{
Name: "SameSite Auth Cookie",
Description: "Controls the 'SameSite' property is set on browser session cookies.",
Description: "Controls if the 'SameSite' property is set on browser session cookies.",
Flag: "samesite-auth-cookie",
Env: "CODER_SAMESITE_AUTH_COOKIE",
// Do not allow "strict" same-site cookies. That would potentially break workspace apps.
@@ -3000,7 +3000,7 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
{
Name: "SSH Config Options",
Description: "These SSH config options will override the default SSH config options. " +
"Provide options in \"key=value\" or \"key value\" format separated by commas." +
"Provide options in \"key=value\" or \"key value\" format separated by commas. " +
"Using this incorrectly can break SSH to your deployment, use cautiously.",
Flag: "ssh-config-options",
Env: "CODER_SSH_CONFIG_OPTIONS",
@@ -3041,7 +3041,7 @@ Write out the current server config as YAML to stdout.`,
{
// Env handling is done in cli.ReadGitAuthFromEnvironment
Name: "External Auth Providers",
Description: "External Authentication providers.",
Description: "Configure external authentication providers for Git and other services.",
YAML: "externalAuthProviders",
Flag: "external-auth-providers",
Value: &c.ExternalAuthConfigs,
@@ -3059,7 +3059,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Proxy Health Check Interval",
Description: "The interval in which coderd should be checking the status of workspace proxies.",
Description: "The interval at which coderd checks the status of workspace proxies.",
Flag: "proxy-health-interval",
Env: "CODER_PROXY_HEALTH_INTERVAL",
Default: (time.Minute).String(),
@@ -3080,7 +3080,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Allow Custom Quiet Hours",
Description: "Allow users to set their own quiet hours schedule for workspaces to stop in (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used.",
Description: "Allow users to set their own quiet hours schedule for when workspaces are stopped (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used.",
Flag: "allow-custom-quiet-hours",
Env: "CODER_ALLOW_CUSTOM_QUIET_HOURS",
Default: "true",
@@ -3192,7 +3192,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Notifications: Email: Hello",
Description: "The hostname identifying the SMTP server.",
Description: "The hostname identifying this client to the SMTP server.",
Flag: "notifications-email-hello",
Env: "CODER_NOTIFICATIONS_EMAIL_HELLO",
Value: &c.Notifications.SMTP.Hello,
@@ -3355,7 +3355,7 @@ Write out the current server config as YAML to stdout.`,
Name: "Notifications: Store Sync Interval",
Description: "The notifications system buffers message updates in memory to ease pressure on the database. " +
"This option controls how often it synchronizes its state with the database. The shorter this value the " +
"lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
"lower the chance of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
"database. It is recommended to keep this option at its default value.",
Flag: "notifications-store-sync-interval",
Env: "CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL",
@@ -3370,7 +3370,7 @@ Write out the current server config as YAML to stdout.`,
Name: "Notifications: Store Sync Buffer Size",
Description: "The notifications system buffers message updates in memory to ease pressure on the database. " +
"This option controls how many updates are kept in memory. The lower this value the " +
"lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
"lower the chance of state inconsistency in a non-graceful shutdown - but it also increases load on the " +
"database. It is recommended to keep this option at its default value.",
Flag: "notifications-store-sync-buffer-size",
Env: "CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE",
@@ -3434,7 +3434,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Reconciliation Backoff Interval",
Description: "Interval to increase reconciliation backoff by when prebuilds fail, after which a retry attempt is made.",
Description: "Amount of time to add to the reconciliation backoff delay after each prebuild failure, before the next retry attempt is made.",
Flag: "workspace-prebuilds-reconciliation-backoff-interval",
Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_INTERVAL",
Value: &c.Prebuilds.ReconciliationBackoffInterval,
@@ -3446,7 +3446,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Reconciliation Backoff Lookback Period",
Description: "Interval to look back to determine number of failed prebuilds, which influences backoff.",
Description: "Time period to look back when counting failed prebuilds to calculate the backoff delay.",
Flag: "workspace-prebuilds-reconciliation-backoff-lookback-period",
Env: "CODER_WORKSPACE_PREBUILDS_RECONCILIATION_BACKOFF_LOOKBACK_PERIOD",
Value: &c.Prebuilds.ReconciliationBackoffLookback,
@@ -3458,7 +3458,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Failure Hard Limit",
Description: "Maximum number of consecutive failed prebuilds before a preset hits the hard limit; disabled when set to zero.",
Description: "Maximum number of consecutive failed prebuilds before a preset is considered hard-limited and stops automatic prebuild creation. Disabled when set to zero.",
Flag: "workspace-prebuilds-failure-hard-limit",
Env: "CODER_WORKSPACE_PREBUILDS_FAILURE_HARD_LIMIT",
Value: &c.Prebuilds.FailureHardLimit,
@@ -3481,7 +3481,7 @@ Write out the current server config as YAML to stdout.`,
// AI Bridge Options
{
Name: "AI Bridge Enabled",
Description: "Whether to start an in-memory aibridged instance.",
Description: "Enable the embedded AI Bridge service to intercept and record AI provider requests.",
Flag: "aibridge-enabled",
Env: "CODER_AIBRIDGE_ENABLED",
Value: &c.AI.BridgeConfig.Enabled,
@@ -3501,7 +3501,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge OpenAI Key",
Description: "The key to authenticate against the OpenAI API.",
Description: "API key for authenticating with the OpenAI API.",
Flag: "aibridge-openai-key",
Env: "CODER_AIBRIDGE_OPENAI_KEY",
Value: &c.AI.BridgeConfig.OpenAI.Key,
@@ -3521,7 +3521,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Anthropic Key",
Description: "The key to authenticate against the Anthropic API.",
Description: "API key for authenticating with the Anthropic API.",
Flag: "aibridge-anthropic-key",
Env: "CODER_AIBRIDGE_ANTHROPIC_KEY",
Value: &c.AI.BridgeConfig.Anthropic.Key,
@@ -3553,7 +3553,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Bedrock Access Key",
Description: "The access key to authenticate against the AWS Bedrock API.",
Description: "AWS access key for authenticating with the AWS Bedrock API.",
Flag: "aibridge-bedrock-access-key",
Env: "CODER_AIBRIDGE_BEDROCK_ACCESS_KEY",
Value: &c.AI.BridgeConfig.Bedrock.AccessKey,
@@ -3563,7 +3563,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Bedrock Access Key Secret",
Description: "The access key secret to use with the access key to authenticate against the AWS Bedrock API.",
Description: "AWS secret access key for authenticating with the AWS Bedrock API.",
Flag: "aibridge-bedrock-access-key-secret",
Env: "CODER_AIBRIDGE_BEDROCK_ACCESS_KEY_SECRET",
Value: &c.AI.BridgeConfig.Bedrock.AccessKeySecret,
@@ -3593,7 +3593,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Inject Coder MCP tools",
Description: "Whether to inject Coder's MCP tools into intercepted AI Bridge requests (requires the \"oauth2\" and \"mcp-server-http\" experiments to be enabled).",
Description: "Enable injection of Coder's MCP tools into intercepted AI Bridge requests. Requires the 'oauth2' and 'mcp-server-http' experiments.",
Flag: "aibridge-inject-coder-mcp-tools",
Env: "CODER_AIBRIDGE_INJECT_CODER_MCP_TOOLS",
Value: &c.AI.BridgeConfig.InjectCoderMCPTools,
@@ -3603,7 +3603,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Data Retention Duration",
Description: "Length of time to retain data such as interceptions and all related records (token, prompt, tool use).",
Description: "How long to retain AI Bridge data including interceptions, tokens, prompts, and tool usage records.",
Flag: "aibridge-retention",
Env: "CODER_AIBRIDGE_RETENTION",
Value: &c.AI.BridgeConfig.Retention,
@@ -3656,7 +3656,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Circuit Breaker Enabled",
Description: "Enable the circuit breaker to protect against cascading failures from upstream AI provider rate limits (429, 503, 529 overloaded).",
Description: "Enable the circuit breaker to protect against cascading failures from upstream AI provider rate limits and overload errors (HTTP 429, 503, 529).",
Flag: "aibridge-circuit-breaker-enabled",
Env: "CODER_AIBRIDGE_CIRCUIT_BREAKER_ENABLED",
Value: &c.AI.BridgeConfig.CircuitBreakerEnabled,
@@ -3666,7 +3666,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Circuit Breaker Failure Threshold",
Description: "Number of consecutive failures that triggers the circuit breaker to open.",
Description: "Number of consecutive failures that trigger the circuit breaker to open.",
Flag: "aibridge-circuit-breaker-failure-threshold",
Env: "CODER_AIBRIDGE_CIRCUIT_BREAKER_FAILURE_THRESHOLD",
Value: serpent.Validate(&c.AI.BridgeConfig.CircuitBreakerFailureThreshold, func(value *serpent.Int64) error {
@@ -3682,7 +3682,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "AI Bridge Circuit Breaker Interval",
Description: "Cyclic period of the closed state for clearing internal failure counts.",
Description: "Time window for counting failures before resetting the failure count in the closed state.",
Flag: "aibridge-circuit-breaker-interval",
Env: "CODER_AIBRIDGE_CIRCUIT_BREAKER_INTERVAL",
Value: &c.AI.BridgeConfig.CircuitBreakerInterval,
@@ -3830,7 +3830,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Workspace Agent Logs Retention",
Description: "How long workspace agent logs are retained. Logs from non-latest builds are deleted if the agent hasn't connected within this period. Logs from the latest build are always retained. Set to 0 to disable automatic deletion.",
Description: "How long workspace agent logs are retained. Logs from non-latest builds are deleted if the agent hasn't connected within this period. Logs from the latest build for each workspace are always retained. Set to 0 to disable automatic deletion.",
Flag: "workspace-agent-logs-retention",
Env: "CODER_WORKSPACE_AGENT_LOGS_RETENTION",
Value: &c.Retention.WorkspaceAgentLogs,
@@ -3841,7 +3841,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Enable Authorization Recordings",
Description: "All api requests will have a header including all authorization calls made during the request. " +
Description: "All API requests will have a header including all authorization calls made during the request. " +
"This is used for debugging purposes and only available for dev builds.",
Required: false,
Flag: "enable-authz-recordings",
+77
View File
@@ -0,0 +1,77 @@
# AI Bridge Proxy
A MITM (Man-in-the-Middle) proxy server for intercepting and decrypting HTTPS requests to AI providers.
## Overview
The AI Bridge Proxy intercepts HTTPS traffic, decrypts it using a configured CA certificate, and forwards requests to AI Bridge for processing.
## Configuration
### Certificate Setup
Generate a CA key pair for MITM:
#### 1. Generate a new private key
```sh
openssl genrsa -out mitm.key 2048
chmod 400 mitm.key
```
#### 2. Create a self-signed CA certificate
```sh
openssl req -new -x509 -days 365 \
-key mitm.key \
-out mitm.crt \
-subj "/CN=Coder AI Bridge Proxy CA"
```
### Configuration options
| Environment Variable | Description | Default |
|------------------------------------|---------------------------------|---------|
| `CODER_AIBRIDGE_PROXY_ENABLED` | Enable the AI Bridge Proxy | `false` |
| `CODER_AIBRIDGE_PROXY_LISTEN_ADDR` | Address the proxy listens on | `:8888` |
| `CODER_AIBRIDGE_PROXY_CERT_FILE` | Path to the CA certificate file | - |
| `CODER_AIBRIDGE_PROXY_KEY_FILE` | Path to the CA private key file | - |
### Client Configuration
Clients must trust the proxy's CA certificate and authenticate with their Coder session token.
#### CA Certificate
Clients need to trust the MITM CA certificate:
```sh
# Node.js
export NODE_EXTRA_CA_CERTS="/path/to/mitm.crt"
# Python (requests, httpx)
export REQUESTS_CA_BUNDLE="/path/to/mitm.crt"
export SSL_CERT_FILE="/path/to/mitm.crt"
# Go
export SSL_CERT_FILE="/path/to/mitm.crt"
```
#### Proxy Authentication
Clients authenticate with the proxy using their Coder session token in the `Proxy-Authorization` header via HTTP Basic Auth.
The token is passed as the password (username is ignored):
```sh
export HTTP_PROXY="http://ignored:<coder-session-token>@<proxy-host>:<proxy-port>"
export HTTPS_PROXY="http://ignored:<coder-session-token>@<proxy-host>:<proxy-port>"
```
For example:
```sh
export HTTP_PROXY="http://coder:${CODER_SESSION_TOKEN}@localhost:8888"
export HTTPS_PROXY="http://coder:${CODER_SESSION_TOKEN}@localhost:8888"
```
Most HTTP clients and AI SDKs will automatically use these environment variables.
+2 -2
View File
@@ -473,7 +473,7 @@ require (
github.com/anthropics/anthropic-sdk-go v1.19.0
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/aibridge v1.0.3
github.com/coder/aibridge v1.0.2
github.com/coder/aisdk-go v0.0.9
github.com/coder/boundary v0.8.0
github.com/coder/preview v1.0.4
@@ -481,7 +481,7 @@ require (
github.com/dgraph-io/ristretto/v2 v2.4.0
github.com/elazarl/goproxy v1.8.0
github.com/fsnotify/fsnotify v1.9.0
github.com/go-git/go-git/v5 v5.16.5
github.com/go-git/go-git/v5 v5.16.2
github.com/icholy/replace v0.6.0
github.com/mark3labs/mcp-go v0.38.0
gonum.org/v1/gonum v0.17.0
+4 -4
View File
@@ -927,8 +927,8 @@ github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
github.com/coder/aibridge v1.0.3 h1:gt3XKbnFBJ/jyls/yanU/iWZO5yhd6LVYuTQbEZ/SxQ=
github.com/coder/aibridge v1.0.3/go.mod h1:c7Of2xfAksZUrPWN180Eh60fiKgzs7dyOjniTjft6AE=
github.com/coder/aibridge v1.0.2 h1:cVPr9+TFLIzULpKPGI/1lnL14+DruedR7KnjZHklIEU=
github.com/coder/aibridge v1.0.2/go.mod h1:c7Of2xfAksZUrPWN180Eh60fiKgzs7dyOjniTjft6AE=
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
github.com/coder/boundary v0.8.0 h1:g/H6VIGY4IoWeKkbvao7zhO1BAQe7upSHfHzoAZxdik=
@@ -1149,8 +1149,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+1 -1
View File
@@ -62,7 +62,7 @@ test("app", async ({ context, page }) => {
const agent = await startAgent(page, token);
// Wait for the web terminal to open in a new tab
const pagePromise = context.waitForEvent("page", { timeout: 10_000 });
const pagePromise = context.waitForEvent("page");
await page.getByText(appName).click({ timeout: 10_000 });
const app = await pagePromise;
await app.waitForLoadState("domcontentloaded");
+1 -1
View File
@@ -32,7 +32,7 @@
"test:watch": "vitest",
"test:watch-jest": "jest --watch",
"stats": "STATS=true pnpm build && npx http-server ./stats -p 8081 -c-1",
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis && cp -f ./node_modules/emoji-datasource-apple/img/apple/sheets-256/64.png ./static/emojis/spritesheet.png"
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis"
},
"dependencies": {
"@emoji-mart/data": "1.2.1",
-1
View File
@@ -36,7 +36,6 @@ declare module "@emoji-mart/react" {
emojiButtonSize?: number;
emojiSize?: number;
emojiVersion?: string;
getSpritesheetURL?: (set: string) => string;
onEmojiSelect: (emoji: EmojiData) => void;
}
+18 -12
View File
@@ -7,7 +7,13 @@ import {
TriangleAlertIcon,
XIcon,
} from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
import {
type FC,
forwardRef,
type PropsWithChildren,
type ReactNode,
useState,
} from "react";
import { cn } from "utils/cn";
const alertVariants = cva(
@@ -125,9 +131,7 @@ export const Alert: FC<AlertProps> = ({
);
};
export const AlertDetail: React.FC<React.PropsWithChildren> = ({
children,
}) => {
export const AlertDetail: FC<PropsWithChildren> = ({ children }) => {
return (
<span className="m-0 text-sm" data-chromatic="ignore">
{children}
@@ -135,11 +139,13 @@ export const AlertDetail: React.FC<React.PropsWithChildren> = ({
);
};
export const AlertTitle: React.FC<React.ComponentPropsWithRef<"h1">> = ({
className,
...props
}) => {
return (
<h1 className={cn("m-0 mb-1 text-sm font-medium", className)} {...props} />
);
};
export const AlertTitle = forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h1
ref={ref}
className={cn("m-0 mb-1 text-sm font-medium", className)}
{...props}
/>
));
+10 -11
View File
@@ -13,6 +13,7 @@
import { useTheme } from "@emotion/react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { getExternalImageStylesFromUrl } from "theme/externalImages";
import { cn } from "utils/cn";
@@ -57,22 +58,17 @@ export type AvatarProps = AvatarPrimitive.AvatarProps &
VariantProps<typeof avatarVariants> & {
src?: string;
fallback?: string;
ref?: React.Ref<React.ComponentRef<typeof AvatarPrimitive.Root>>;
};
export const Avatar: React.FC<AvatarProps> = ({
className,
size,
variant,
src,
fallback,
children,
...props
}) => {
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
AvatarProps
>(({ className, size, variant, src, fallback, children, ...props }, ref) => {
const theme = useTheme();
return (
<AvatarPrimitive.Root
ref={ref}
className={cn(avatarVariants({ size, variant, className }))}
{...props}
>
@@ -89,4 +85,7 @@ export const Avatar: React.FC<AvatarProps> = ({
{children}
</AvatarPrimitive.Root>
);
};
});
Avatar.displayName = AvatarPrimitive.Root.displayName;
export { Avatar };
+24 -21
View File
@@ -4,6 +4,7 @@
*/
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef } from "react";
import { cn } from "utils/cn";
const badgeVariants = cva(
@@ -57,26 +58,28 @@ const badgeVariants = cva(
},
);
type BadgeProps = React.ComponentPropsWithRef<"div"> &
VariantProps<typeof badgeVariants> & {
asChild?: boolean;
};
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {
asChild?: boolean;
}
export const Badge: React.FC<BadgeProps> = ({
className,
variant,
size,
border,
hover,
asChild = false,
...props
}) => {
const Comp = asChild ? Slot : "div";
export const Badge = forwardRef<HTMLDivElement, BadgeProps>(
(
{ className, variant, size, border, hover, asChild = false, ...props },
ref,
) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
{...props}
className={cn(badgeVariants({ variant, size, border, hover }), className)}
/>
);
};
return (
<Comp
{...props}
ref={ref}
className={cn(
badgeVariants({ variant, size, border, hover }),
className,
)}
/>
);
},
);
+20 -14
View File
@@ -1,7 +1,13 @@
import { Badge } from "components/Badge/Badge";
import { Stack } from "components/Stack/Stack";
import {
type FC,
forwardRef,
type HTMLAttributes,
type PropsWithChildren,
} from "react";
export const EnabledBadge: React.FC = () => {
export const EnabledBadge: FC = () => {
return (
<Badge className="option-enabled" variant="green" border="solid">
Enabled
@@ -9,25 +15,25 @@ export const EnabledBadge: React.FC = () => {
);
};
export const EntitledBadge: React.FC = () => {
export const EntitledBadge: FC = () => {
return (
<Badge border="solid" variant="green">
Entitled
</Badge>
);
};
export const DisabledBadge: React.FC<React.ComponentPropsWithRef<"div">> = ({
...props
}) => {
export const DisabledBadge: FC = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>((props, ref) => {
return (
<Badge {...props} className="option-disabled">
<Badge ref={ref} {...props} className="option-disabled">
Disabled
</Badge>
);
};
});
export const EnterpriseBadge: React.FC = () => {
export const EnterpriseBadge: FC = () => {
return (
<Badge variant="info" border="solid">
Enterprise
@@ -39,7 +45,7 @@ interface PremiumBadgeProps {
children?: React.ReactNode;
}
export const PremiumBadge: React.FC<PremiumBadgeProps> = ({
export const PremiumBadge: FC<PremiumBadgeProps> = ({
children = "Premium",
}) => {
return (
@@ -49,7 +55,7 @@ export const PremiumBadge: React.FC<PremiumBadgeProps> = ({
);
};
export const PreviewBadge: React.FC = () => {
export const PreviewBadge: FC = () => {
return (
<Badge variant="purple" border="solid">
Preview
@@ -57,7 +63,7 @@ export const PreviewBadge: React.FC = () => {
);
};
export const AlphaBadge: React.FC = () => {
export const AlphaBadge: FC = () => {
return (
<Badge variant="purple" border="solid">
Alpha
@@ -65,7 +71,7 @@ export const AlphaBadge: React.FC = () => {
);
};
export const DeprecatedBadge: React.FC = () => {
export const DeprecatedBadge: FC = () => {
return (
<Badge variant="warning" border="solid">
Deprecated
@@ -73,7 +79,7 @@ export const DeprecatedBadge: React.FC = () => {
);
};
export const Badges: React.FC<React.PropsWithChildren> = ({ children }) => {
export const Badges: FC<PropsWithChildren> = ({ children }) => {
return (
<Stack
css={{ margin: "0 0 16px" }}
+89 -91
View File
@@ -4,59 +4,62 @@
*/
import { Slot } from "@radix-ui/react-slot";
import { MoreHorizontal } from "lucide-react";
import {
type ComponentProps,
type ComponentPropsWithoutRef,
type FC,
forwardRef,
type ReactNode,
} from "react";
import { cn } from "utils/cn";
type BreadcrumbProps = React.ComponentPropsWithRef<"nav"> & {
separator?: React.ReactNode;
};
export const Breadcrumb = forwardRef<
HTMLElement,
ComponentPropsWithoutRef<"nav"> & {
separator?: ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
export const Breadcrumb: React.FC<BreadcrumbProps> = ({ ...props }) => {
return <nav aria-label="breadcrumb" {...props} />;
};
export const BreadcrumbList = forwardRef<
HTMLOListElement,
ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center text-sm pl-6 my-4 gap-1.5 break-words font-medium list-none sm:gap-2.5",
className,
)}
{...props}
/>
));
export const BreadcrumbList: React.FC<React.ComponentPropsWithRef<"ol">> = ({
className,
...props
}) => {
return (
<ol
className={cn(
"flex flex-wrap items-center text-sm pl-6 my-4 gap-1.5 break-words font-medium list-none sm:gap-2.5",
className,
)}
{...props}
/>
);
};
export const BreadcrumbItem = forwardRef<
HTMLLIElement,
ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn(
"inline-flex items-center gap-1.5 text-content-secondary",
className,
)}
{...props}
/>
));
export const BreadcrumbItem: React.FC<React.ComponentPropsWithRef<"li">> = ({
className,
...props
}) => {
return (
<li
className={cn(
"inline-flex items-center gap-1.5 text-content-secondary",
className,
)}
{...props}
/>
);
};
type BreadcrumbLinkProps = React.ComponentPropsWithRef<"a"> & {
asChild?: boolean;
};
export const BreadcrumbLink: React.FC<BreadcrumbLinkProps> = ({
asChild,
className,
...props
}) => {
export const BreadcrumbLink = forwardRef<
HTMLAnchorElement,
ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn(
"text-content-secondary transition-colors hover:text-content-primary no-underline hover:underline",
className,
@@ -64,54 +67,49 @@ export const BreadcrumbLink: React.FC<BreadcrumbLinkProps> = ({
{...props}
/>
);
};
});
export const BreadcrumbPage: React.FC<React.ComponentPropsWithRef<"span">> = ({
export const BreadcrumbPage = forwardRef<
HTMLSpanElement,
ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
aria-current="page"
className={cn("flex items-center gap-2 text-content-secondary", className)}
{...props}
/>
));
export const BreadcrumbSeparator: FC<ComponentProps<"li">> = ({
children,
className,
...props
}) => {
return (
<span
aria-current="page"
className={cn(
"flex items-center gap-2 text-content-secondary",
className,
)}
{...props}
/>
);
};
}) => (
<li
role="presentation"
aria-hidden="true"
className={cn(
"text-content-disabled [&>svg]:w-3.5 [&>svg]:h-3.5",
className,
)}
{...props}
>
/
</li>
);
export const BreadcrumbSeparator: React.FC<
Omit<React.ComponentPropsWithRef<"li">, "children">
> = ({ className, ...props }) => {
return (
<li
role="presentation"
aria-hidden="true"
className={cn(
"text-content-disabled [&>svg]:w-3.5 [&>svg]:h-3.5",
className,
)}
{...props}
>
/
</li>
);
};
export const BreadcrumbEllipsis: React.FC<
Omit<React.ComponentPropsWithRef<"span">, "children">
> = ({ className, ...props }) => {
return (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
};
export const BreadcrumbEllipsis: FC<ComponentProps<"span">> = ({
className,
...props
}) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
+28 -30
View File
@@ -4,6 +4,7 @@
*/
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef } from "react";
import { cn } from "utils/cn";
// Be careful when changing the child styles from the button such as images
@@ -57,34 +58,31 @@ const buttonVariants = cva(
},
);
export type ButtonProps = React.ComponentPropsWithRef<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
className,
variant,
size,
asChild = false,
...props
}) => {
const Comp = asChild ? Slot : "button";
// We want `type` to default to `"button"` when the component is not being
// used as a `Slot`. The default behavior of any given `<button>` element is
// to submit the closest parent `<form>` because Web Platform reasons. This
// prevents that. However, we don't want to set it on non-`<button>`s when
// `asChild` is set.
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#type
if (!asChild && !props.type) {
props.type = "button";
}
return (
<Comp
{...props}
className={cn(buttonVariants({ variant, size }), className)}
/>
);
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
{...props}
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
// Adding default button type to make sure that buttons don't
// accidentally trigger form actions when clicked. But because
// this Button component is so polymorphic (it's also used to
// make <a> elements look like buttons), we can only safely
// default to adding the prop when we know that we're rendering
// a real HTML button instead of an arbitrary Slot. Adding the
// type attribute to any non-buttons will produce invalid HTML
type={
props.type === undefined && Comp === "button" ? "button" : props.type
}
/>
);
},
);
+26 -25
View File
@@ -4,40 +4,41 @@
*/
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check, Minus } from "lucide-react";
import * as React from "react";
import { cn } from "utils/cn";
/**
* To allow for an indeterminate state the checkbox must be controlled, otherwise the checked prop would remain undefined
*/
export const Checkbox: React.FC<
React.ComponentPropsWithRef<typeof CheckboxPrimitive.Root>
> = ({ className, ...props }) => {
return (
<CheckboxPrimitive.Root
className={cn(
`peer size-[18px] shrink-0 rounded-sm border border-border border-solid
export const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
`peer size-[18px] shrink-0 rounded-sm border border-border border-solid
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-content-link focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
disabled:cursor-not-allowed disabled:bg-surface-primary disabled:data-[state=checked]:bg-surface-tertiary
data-[state=unchecked]:bg-surface-primary
data-[state=checked]:bg-surface-invert-primary data-[state=checked]:text-content-invert
hover:enabled:border-border-hover hover:data-[state=checked]:bg-surface-invert-secondary`,
className,
)}
{...props}
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current relative")}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current relative")}
>
<div className="flex">
{(props.checked === true || props.defaultChecked === true) && (
<Check className="w-4 h-4" strokeWidth={2.5} />
)}
{props.checked === "indeterminate" && (
<Minus className="w-4 h-4" strokeWidth={2.5} />
)}
</div>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
};
<div className="flex">
{(props.checked === true || props.defaultChecked === true) && (
<Check className="w-4 h-4" strokeWidth={2.5} />
)}
{props.checked === "indeterminate" && (
<Minus className="w-4 h-4" strokeWidth={2.5} />
)}
</div>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
@@ -1,16 +1,19 @@
import { useTheme } from "@emotion/react";
import { forwardRef, type ImgHTMLAttributes } from "react";
import { getExternalImageStylesFromUrl } from "theme/externalImages";
export const ExternalImage: React.FC<React.ComponentPropsWithRef<"img">> = ({
...props
}) => {
export const ExternalImage = forwardRef<
HTMLImageElement,
ImgHTMLAttributes<HTMLImageElement>
>((props, ref) => {
const theme = useTheme();
return (
// biome-ignore lint/a11y/useAltText: alt should be passed in as a prop
<img
ref={ref}
css={getExternalImageStylesFromUrl(theme.externalImages, props.src)}
{...props}
/>
);
};
});
+41 -37
View File
@@ -5,6 +5,7 @@ import {
type ComponentProps,
createContext,
type FC,
forwardRef,
type HTMLProps,
type ReactNode,
useContext,
@@ -75,50 +76,53 @@ interface FormSectionProps {
};
alpha?: boolean;
deprecated?: boolean;
ref?: React.Ref<HTMLElement>;
}
export const FormSection: FC<FormSectionProps> = ({
children,
title,
description,
classes = {},
alpha = false,
deprecated = false,
ref,
}) => {
const { direction } = useContext(FormContext);
export const FormSection = forwardRef<HTMLDivElement, FormSectionProps>(
(
{
children,
title,
description,
classes = {},
alpha = false,
deprecated = false,
},
ref,
) => {
const { direction } = useContext(FormContext);
return (
<section
ref={ref}
css={[
styles.formSection,
direction === "horizontal" && styles.formSectionHorizontal,
]}
className={classes.root}
>
<div
return (
<section
ref={ref}
css={[
styles.formSectionInfo,
direction === "horizontal" && styles.formSectionInfoHorizontal,
styles.formSection,
direction === "horizontal" && styles.formSectionHorizontal,
]}
className={classes.sectionInfo}
className={classes.root}
>
<header className="flex items-center gap-4">
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
{title}
</h2>
{alpha && <AlphaBadge />}
{deprecated && <DeprecatedBadge />}
</header>
<div css={styles.formSectionInfoDescription}>{description}</div>
</div>
<div
css={[
styles.formSectionInfo,
direction === "horizontal" && styles.formSectionInfoHorizontal,
]}
className={classes.sectionInfo}
>
<header className="flex items-center gap-4">
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
{title}
</h2>
{alpha && <AlphaBadge />}
{deprecated && <DeprecatedBadge />}
</header>
<div css={styles.formSectionInfoDescription}>{description}</div>
</div>
{children}
</section>
);
};
{children}
</section>
);
},
);
export const FormFields: FC<ComponentProps<typeof Stack>> = (props) => {
return (
@@ -25,7 +25,7 @@ const custom = [
type EmojiPickerProps = Omit<
ComponentProps<typeof EmojiMart>,
"custom" | "data" | "set" | "theme" | "getSpritesheetURL"
"custom" | "data" | "set" | "theme"
>;
const EmojiPicker: FC<EmojiPickerProps> = (props) => {
@@ -53,7 +53,6 @@ const EmojiPicker: FC<EmojiPickerProps> = (props) => {
emojiVersion="15"
data={data}
custom={custom}
getSpritesheetURL={() => "/emojis/spritesheet.png"}
{...props}
/>
);
+7 -6
View File
@@ -2,13 +2,13 @@
* Copied from shadc/ui on 11/13/2024
* @see {@link https://ui.shadcn.com/docs/components/input}
*/
import { forwardRef } from "react";
import { cn } from "utils/cn";
export const Input: React.FC<React.ComponentPropsWithRef<"input">> = ({
className,
type,
...props
}) => {
export const Input = forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
@@ -23,7 +23,8 @@ export const Input: React.FC<React.ComponentPropsWithRef<"input">> = ({
`,
className,
)}
ref={ref}
{...props}
/>
);
};
});
+12 -7
View File
@@ -1,9 +1,10 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Button, type ButtonProps } from "components/Button/Button";
import { Input } from "components/Input/Input";
import { type FC, forwardRef } from "react";
import { cn } from "utils/cn";
export const InputGroup: React.FC<React.ComponentProps<"div">> = ({
const InputGroup: FC<React.ComponentProps<"div">> = ({
className,
...props
}) => {
@@ -41,7 +42,7 @@ const inputGroupAddonVariants = cva(
},
);
export const InputGroupAddon: React.FC<
const InputGroupAddon: FC<
React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>
> = ({ className, align = "inline-start", ...props }) => {
return (
@@ -62,11 +63,13 @@ export const InputGroupAddon: React.FC<
);
};
export const InputGroupInput: React.FC<
React.ComponentPropsWithRef<typeof Input>
> = ({ className, ...props }) => {
const InputGroupInput = forwardRef<
HTMLInputElement,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
className={cn(
// Reset Input's default styles that conflict with group
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0",
@@ -78,9 +81,9 @@ export const InputGroupInput: React.FC<
{...props}
/>
);
};
});
export const InputGroupButton: React.FC<ButtonProps> = ({
const InputGroupButton: FC<ButtonProps> = ({
className,
size = "sm",
variant = "subtle",
@@ -99,3 +102,5 @@ export const InputGroupButton: React.FC<ButtonProps> = ({
/>
);
};
export { InputGroup, InputGroupAddon, InputGroupInput, InputGroupButton };
+12 -11
View File
@@ -4,20 +4,21 @@
*/
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef } from "react";
import { cn } from "utils/cn";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
type LabelProps = React.ComponentPropsWithRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>;
export const Label: React.FC<LabelProps> = ({ className, ...props }) => {
return (
<LabelPrimitive.Root
className={cn(labelVariants(), className)}
{...props}
/>
);
};
export const Label = forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
+25 -22
View File
@@ -1,6 +1,7 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { SquareArrowOutUpRightIcon } from "lucide-react";
import { forwardRef } from "react";
import { cn } from "utils/cn";
const linkVariants = cva(
@@ -22,26 +23,28 @@ const linkVariants = cva(
},
);
type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> &
VariantProps<typeof linkVariants> & {
asChild?: boolean;
showExternalIcon?: boolean;
ref?: React.Ref<HTMLAnchorElement>;
};
interface LinkProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement>,
VariantProps<typeof linkVariants> {
asChild?: boolean;
showExternalIcon?: boolean;
}
export const Link: React.FC<LinkProps> = ({
className,
children,
size,
asChild,
showExternalIcon = true,
...props
}) => {
const Comp = asChild ? Slot : "a";
return (
<Comp className={cn(linkVariants({ size }), className)} {...props}>
<Slottable>{children}</Slottable>
{showExternalIcon && <SquareArrowOutUpRightIcon />}
</Comp>
);
};
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
(
{ className, children, size, asChild, showExternalIcon = true, ...props },
ref,
) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
className={cn(linkVariants({ size }), className)}
ref={ref}
{...props}
>
<Slottable>{children}</Slottable>
{showExternalIcon && <SquareArrowOutUpRightIcon aria-hidden="true" />}
</Comp>
);
},
);
+36 -26
View File
@@ -2,10 +2,16 @@ import type { Interpolation, Theme } from "@emotion/react";
import CircularProgress, {
type CircularProgressProps,
} from "@mui/material/CircularProgress";
import { type FC, type ReactNode, useMemo } from "react";
import {
type FC,
forwardRef,
type HTMLAttributes,
type ReactNode,
useMemo,
} from "react";
import type { ThemeRole } from "theme/roles";
type PillProps = React.ComponentPropsWithRef<"div"> & {
type PillProps = HTMLAttributes<HTMLDivElement> & {
icon?: ReactNode;
type?: ThemeRole;
size?: "md" | "lg";
@@ -23,31 +29,35 @@ const PILL_HEIGHT = 24;
const PILL_ICON_SIZE = 14;
const PILL_ICON_SPACING = (PILL_HEIGHT - PILL_ICON_SIZE) / 2;
export const Pill: FC<PillProps> = ({
icon,
type = "inactive",
children,
size = "md",
...divProps
}) => {
const typeStyles = useMemo(() => themeStyles(type), [type]);
export const Pill: FC<PillProps> = forwardRef<HTMLDivElement, PillProps>(
(props, ref) => {
const {
icon,
type = "inactive",
children,
size = "md",
...divProps
} = props;
const typeStyles = useMemo(() => themeStyles(type), [type]);
return (
<div
css={[
styles.pill,
Boolean(icon) && size === "md" && styles.pillWithIcon,
size === "lg" && styles.pillLg,
Boolean(icon) && size === "lg" && styles.pillLgWithIcon,
typeStyles,
]}
{...divProps}
>
{icon}
{children}
</div>
);
};
return (
<div
ref={ref}
css={[
styles.pill,
Boolean(icon) && size === "md" && styles.pillWithIcon,
size === "lg" && styles.pillLg,
Boolean(icon) && size === "lg" && styles.pillLgWithIcon,
typeStyles,
]}
{...divProps}
>
{icon}
{children}
</div>
);
},
);
export const PillSpinner: FC<CircularProgressProps> = (props) => {
return (
+30 -25
View File
@@ -3,6 +3,11 @@
* @see {@link https://ui.shadcn.com/docs/components/popover}
*/
import * as PopoverPrimitive from "@radix-ui/react-popover";
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef,
} from "react";
import { cn } from "utils/cn";
export type PopoverContentProps = PopoverPrimitive.PopoverContentProps;
@@ -13,28 +18,28 @@ export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverContent: React.FC<
React.ComponentPropsWithRef<typeof PopoverPrimitive.Content>
> = ({ className, align = "center", sideOffset = 4, ...props }) => {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={sideOffset}
collisionPadding={16}
className={cn(
`z-50 w-72 rounded-md border border-solid bg-surface-primary
text-content-primary shadow-md outline-none
max-h-[var(--radix-popper-available-height)] overflow-y-auto
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
};
export const PopoverContent = forwardRef<
ElementRef<typeof PopoverPrimitive.Content>,
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={16}
className={cn(
`z-50 w-72 rounded-md border border-solid bg-surface-primary
text-content-primary shadow-md outline-none
max-h-[var(--radix-popper-available-height)] overflow-y-auto
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
+18 -12
View File
@@ -4,30 +4,36 @@
*/
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import * as React from "react";
import { cn } from "utils/cn";
export const RadioGroup: React.FC<
React.ComponentPropsWithRef<typeof RadioGroupPrimitive.Root>
> = ({ className, ...props }) => {
export const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
};
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
export const RadioGroupItem: React.FC<
React.ComponentPropsWithRef<typeof RadioGroupPrimitive.Item>
> = ({ className, ...props }) => {
export const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
`relative aspect-square h-4 w-4 rounded-full border border-solid border-border text-content-primary bg-surface-primary
focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link
focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary
hover:border-border-hover data-[state=checked]:border-border-hover`,
focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link
focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary
hover:border-border-hover data-[state=checked]:border-border-hover`,
className,
)}
{...props}
@@ -37,4 +43,4 @@ export const RadioGroupItem: React.FC<
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
};
});
+38 -36
View File
@@ -3,42 +3,44 @@
* @see {@link https://ui.shadcn.com/docs/components/scroll-area}
*/
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "utils/cn";
export const ScrollArea: React.FC<
React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.Root>
> = ({ className, children, ...props }) => {
return (
<ScrollAreaPrimitive.Root
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar className="z-10" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
};
export const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar className="z-10" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
export const ScrollBar: React.FC<
React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
> = ({ className, orientation = "vertical", ...props }) => {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
"border-0 border-solid border-border flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-surface-quaternary" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
};
export const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"border-0 border-solid border-border flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-surface-quaternary" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
+71 -44
View File
@@ -4,6 +4,7 @@
*/
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "utils/cn";
export const Select = SelectPrimitive.Root;
@@ -12,16 +13,17 @@ export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
export type SelectTriggerProps = React.ComponentPropsWithRef<
export type SelectTriggerProps = React.ComponentPropsWithoutRef<
typeof SelectPrimitive.Trigger
>;
export const SelectTrigger: React.FC<SelectTriggerProps> = ({
className,
children,
...props
}) => (
export const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
SelectTriggerProps
>(({ className, children, id, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
id={id}
className={cn(
`flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md
border border-border border-solid bg-transparent px-3 py-2 text-sm shadow-sm
@@ -37,12 +39,15 @@ export const SelectTrigger: React.FC<SelectTriggerProps> = ({
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollUpButton>
> = ({ className, ...props }) => (
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
@@ -51,29 +56,34 @@ const SelectScrollUpButton: React.FC<
>
<ChevronUp className="size-icon-sm" />
</SelectPrimitive.ScrollUpButton>
);
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollDownButton>
> = ({ className, ...props }) => {
return (
<SelectPrimitive.ScrollDownButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</SelectPrimitive.ScrollDownButton>
);
};
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
export const SelectContent: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Content>
> = ({ className, children, position = "popper", ...props }) => (
export const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border ",
"border-border border-solid bg-surface-primary text-content-primary shadow-md ",
@@ -102,23 +112,27 @@ export const SelectContent: React.FC<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
export const SelectLabel: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Label>
> = ({ className, ...props }) => {
return (
<SelectPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
);
};
export const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
export const SelectItem: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Item>
> = ({ className, children, ...props }) => (
export const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ",
"pl-2 pr-8 text-sm text-content-secondary outline-none focus:bg-surface-secondary ",
@@ -134,4 +148,17 @@ export const SelectItem: React.FC<
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
@@ -16,6 +16,7 @@ import { CheckIcon, ChevronDownIcon } from "lucide-react";
import {
Children,
type FC,
forwardRef,
type HTMLProps,
isValidElement,
type ReactElement,
@@ -45,16 +46,15 @@ type SelectMenuButtonProps = ButtonProps & {
startIcon?: React.ReactNode;
};
export const SelectMenuButton: React.FC<SelectMenuButtonProps> = ({
className,
startIcon,
children,
...props
}) => {
export const SelectMenuButton = forwardRef<
HTMLButtonElement,
SelectMenuButtonProps
>(({ className, startIcon, children, ...props }, ref) => {
return (
<Button
variant="outline"
size="lg"
ref={ref}
// Shrink padding right slightly to account for visual weight of
// the chevron
className={cn("flex flex-row gap-2 pr-1.5", className)}
@@ -67,7 +67,7 @@ export const SelectMenuButton: React.FC<SelectMenuButtonProps> = ({
<ChevronDownIcon />
</Button>
);
};
});
export const SelectMenuSearch: FC<SearchFieldProps> = ({
className,
+25 -24
View File
@@ -3,35 +3,36 @@
* @see {@link https://ui.shadcn.com/docs/components/slider}
*/
import * as SliderPrimitive from "@radix-ui/react-slider";
import * as React from "react";
import { cn } from "utils/cn";
export const Slider: React.FC<
React.ComponentPropsWithRef<typeof SliderPrimitive.Root>
> = ({ className, ...props }) => {
return (
<SliderPrimitive.Root
className={cn(
"relative flex w-full items-center h-1.5",
className,
"touch-none select-none",
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-surface-secondary data-[disabled]:opacity-40">
<SliderPrimitive.Range className="absolute h-full bg-content-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
export const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full items-center h-1.5",
className,
"touch-none select-none",
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-surface-secondary data-[disabled]:opacity-40">
<SliderPrimitive.Range className="absolute h-full bg-content-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
focus-visible:outline-none hover:border-content-primary
focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary
disabled:pointer-events-none data-[disabled]:opacity-100 data-[disabled]:border-border"
/>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
/>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
focus-visible:outline-none hover:border-content-primary
focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary
disabled:pointer-events-none data-[disabled]:opacity-100 data-[disabled]:border-border"
/>
</SliderPrimitive.Root>
);
};
/>
</SliderPrimitive.Root>
));
+9 -4
View File
@@ -1,18 +1,22 @@
import type { CSSObject } from "@emotion/react";
import { forwardRef } from "react";
type StackProps = React.ComponentPropsWithRef<"div"> & {
/**
* @deprecated Stack component is deprecated. Use Tailwind flex utilities instead.
*/
type StackProps = {
className?: string;
direction?: "column" | "row";
spacing?: number;
alignItems?: CSSObject["alignItems"];
justifyContent?: CSSObject["justifyContent"];
wrap?: CSSObject["flexWrap"];
};
} & React.HTMLProps<HTMLDivElement>;
/**
* @deprecated Stack component is deprecated. Use Tailwind flex utilities instead.
*/
export const Stack: React.FC<StackProps> = (props) => {
export const Stack = forwardRef<HTMLDivElement, StackProps>((props, ref) => {
const {
children,
direction = "column",
@@ -26,6 +30,7 @@ export const Stack: React.FC<StackProps> = (props) => {
return (
<div
{...divProps}
ref={ref}
css={{
display: "flex",
flexDirection: direction,
@@ -39,4 +44,4 @@ export const Stack: React.FC<StackProps> = (props) => {
{children}
</div>
);
};
});
@@ -4,7 +4,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { createContext, type FC, useContext } from "react";
import { createContext, type FC, forwardRef, useContext } from "react";
import { cn } from "utils/cn";
const statusIndicatorVariants = cva(
@@ -34,24 +34,23 @@ type StatusIndicatorContextValue = VariantProps<typeof statusIndicatorVariants>;
const StatusIndicatorContext = createContext<StatusIndicatorContextValue>({});
export type StatusIndicatorProps = React.ComponentPropsWithRef<"div"> &
StatusIndicatorContextValue;
export interface StatusIndicatorProps
extends React.HTMLAttributes<HTMLDivElement>,
StatusIndicatorContextValue {}
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
size,
variant,
className,
...props
}) => {
return (
<StatusIndicatorContext.Provider value={{ size, variant }}>
<div
className={cn(statusIndicatorVariants({ variant, size }), className)}
{...props}
/>
</StatusIndicatorContext.Provider>
);
};
export const StatusIndicator = forwardRef<HTMLDivElement, StatusIndicatorProps>(
({ size, variant, className, ...props }, ref) => {
return (
<StatusIndicatorContext.Provider value={{ size, variant }}>
<div
ref={ref}
className={cn(statusIndicatorVariants({ variant, size }), className)}
{...props}
/>
</StatusIndicatorContext.Provider>
);
},
);
const dotVariants = cva("rounded-full inline-block border-4 border-solid", {
variants: {
+7 -4
View File
@@ -3,11 +3,13 @@
* @see {@link https://ui.shadcn.com/docs/components/switch}
*/
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { forwardRef } from "react";
import { cn } from "utils/cn";
export const Switch: React.FC<
React.ComponentPropsWithRef<typeof SwitchPrimitives.Root>
> = ({ className, ...props }) => (
export const Switch = forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
`peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full shadow-sm transition-colors
@@ -21,6 +23,7 @@ export const Switch: React.FC<
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
@@ -29,4 +32,4 @@ export const Switch: React.FC<
)}
/>
</SwitchPrimitives.Root>
);
));
+8 -6
View File
@@ -1,13 +1,14 @@
/**
* Copied from shadc/ui on 11/13/2024
* Copied from shadc/ui on 04/18/2025
* @see {@link https://ui.shadcn.com/docs/components/textarea}
*/
import * as React from "react";
import { cn } from "utils/cn";
export const Textarea: React.FC<React.ComponentPropsWithRef<"textarea">> = ({
className,
...props
}) => {
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
@@ -17,7 +18,8 @@ export const Textarea: React.FC<React.ComponentPropsWithRef<"textarea">> = ({
disabled:cursor-not-allowed disabled:opacity-50 disabled:text-content-disabled md:text-sm`,
className,
)}
ref={ref}
{...props}
/>
);
};
});
@@ -1,19 +1,18 @@
import { TableRow, type TableRowProps } from "components/Table/Table";
import { forwardRef } from "react";
import { cn } from "utils/cn";
interface TimelineEntryProps extends TableRowProps {
ref?: React.Ref<HTMLTableRowElement>;
clickable?: boolean;
}
export const TimelineEntry: React.FC<TimelineEntryProps> = ({
children,
clickable = true,
className,
...props
}) => {
export const TimelineEntry = forwardRef<
HTMLTableRowElement,
TimelineEntryProps
>(({ children, clickable = true, className, ...props }, ref) => {
return (
<TableRow
ref={ref}
className={cn(
"focus:outline focus:-outline-offset-1 focus:outline-2 focus:outline-content-primary ",
"[&_td]:relative [&_td]:overflow-hidden",
@@ -26,4 +25,4 @@ export const TimelineEntry: React.FC<TimelineEntryProps> = ({
{children}
</TableRow>
);
};
});
+9 -9
View File
@@ -1,8 +1,9 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
/**
* Copied from shadc/ui on 02/05/2025
* @see {@link https://ui.shadcn.com/docs/components/tooltip}
*/
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "utils/cn";
export const TooltipProvider = TooltipPrimitive.Provider;
@@ -15,20 +16,19 @@ export const TooltipTrigger = TooltipPrimitive.Trigger;
export const TooltipArrow = TooltipPrimitive.Arrow;
export type TooltipContentProps = React.ComponentPropsWithRef<
export type TooltipContentProps = React.ComponentPropsWithoutRef<
typeof TooltipPrimitive.Content
> & {
disablePortal?: boolean;
};
export const TooltipContent: React.FC<TooltipContentProps> = ({
className,
sideOffset = 4,
disablePortal,
...props
}) => {
export const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
TooltipContentProps
>(({ className, sideOffset = 4, disablePortal, ...props }, ref) => {
const content = (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-surface-primary px-3 py-2 text-xs font-medium text-content-secondary",
@@ -47,4 +47,4 @@ export const TooltipContent: React.FC<TooltipContentProps> = ({
) : (
<TooltipPrimitive.Portal>{content}</TooltipPrimitive.Portal>
);
};
});
@@ -1,29 +1,35 @@
import { Button, type ButtonProps } from "components/Button/Button";
import { BellIcon } from "lucide-react";
import { forwardRef } from "react";
import { cn } from "utils/cn";
import { UnreadBadge } from "./UnreadBadge";
type InboxButtonProps = ButtonProps & {
type InboxButtonProps = {
unreadCount: number;
};
} & ButtonProps;
export const InboxButton: React.FC<InboxButtonProps> = ({
unreadCount,
...props
}) => {
return (
<Button size="icon-lg" variant="outline" className="relative" {...props}>
<BellIcon />
{unreadCount > 0 && (
<UnreadBadge
count={unreadCount}
className={cn([
"[--offset:calc(var(--unread-badge-size)/2)]",
"absolute top-0 right-0 -mr-[--offset] -mt-[--offset]",
"animate-in fade-in zoom-in duration-200",
])}
/>
)}
</Button>
);
};
export const InboxButton = forwardRef<HTMLButtonElement, InboxButtonProps>(
({ unreadCount, ...props }, ref) => {
return (
<Button
size="icon-lg"
variant="outline"
className="relative"
ref={ref}
{...props}
>
<BellIcon />
{unreadCount > 0 && (
<UnreadBadge
count={unreadCount}
className={cn([
"[--offset:calc(var(--unread-badge-size)/2)]",
"absolute top-0 right-0 -mr-[--offset] -mt-[--offset]",
"animate-in fade-in zoom-in duration-200",
])}
/>
)}
</Button>
);
},
);
+6 -4
View File
@@ -1,5 +1,7 @@
import { Button, type ButtonProps } from "components/Button/Button";
export const AgentButton: React.FC<ButtonProps> = ({ ...props }) => {
return <Button variant="outline" {...props} />;
};
import { forwardRef } from "react";
export const AgentButton = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
return <Button variant="outline" ref={ref} {...props} />;
},
);
@@ -18,7 +18,6 @@ type PromptSelectTriggerProps = SelectTriggerProps & {
export const PromptSelectTrigger: FC<PromptSelectTriggerProps> = ({
className,
tooltip,
children,
...props
}) => {
return (
@@ -28,19 +27,12 @@ export const PromptSelectTrigger: FC<PromptSelectTriggerProps> = ({
<SelectTrigger
{...props}
className={cn([
`w-full md:w-auto max-w-full overflow-hidden border-0 bg-surface-secondary text-sm text-content-primary gap-2 px-4 md:px-3
[&_svg]:text-inherit cursor-pointer hover:bg-surface-quaternary rounded-full
h-10 md:h-8 data-[state=open]:bg-surface-tertiary`,
className,
`w-auto border-0 bg-surface-secondary text-sm text-content-primary gap-2 px-3
[&_svg]:text-inherit cursor-pointer hover:bg-surface-quaternary rounded-full
h-8 data-[state=open]:bg-surface-tertiary`,
])}
>
<span
data-slot="value"
className="overflow-hidden min-w-0 flex items-center gap-2"
>
{children}
</span>
</SelectTrigger>
/>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
@@ -473,28 +473,6 @@ export const CheckExternalAuthOnChangingVersions: Story = {
},
};
// Regression test introduced in https://github.com/coder/coder/pull/22032
// A change was introduced that cause the focused selector to be mostly
// hidden due to an introduced `overflow-hidden`. The change wasn't spotted
// in the PR it was introduced as no stories triggered that behavior, so we
// have added one to ensure the behavior doesn't regress.
export const PresetSelectorFocused: Story = {
beforeEach: () => {
spyOn(API, "getTemplateVersionPresets").mockResolvedValue(
MockPresets.map((preset, i) => ({
...preset,
Icon: i === 0 ? "/icon/code.svg" : i === 1 ? "/icon/database.svg" : "",
Description: i === 0 ? "For everyday development work" : "",
})),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const presetSelect = await canvas.findByLabelText(/preset/i);
presetSelect.focus();
},
};
export const CheckPresetsWhenChangingTemplate: Story = {
args: {
templates: [
@@ -8,7 +8,6 @@ import type {
TemplateVersionExternalAuth,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
@@ -236,7 +235,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
{externalAuthError && <ErrorAlert error={externalAuthError} />}
<fieldset
className="border border-border border-solid rounded-3xl p-3 bg-surface-secondary min-w-0"
className="border border-border border-solid rounded-3xl p-3 bg-surface-secondary"
disabled={createTaskMutation.isPending}
>
<label htmlFor="prompt" className="sr-only">
@@ -249,9 +248,9 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
isSubmitting={createTaskMutation.isPending}
onKeyDown={handleKeyDown}
/>
<div className="flex items-center justify-between pt-2 gap-2">
<div className="flex items-center gap-1 flex-1 min-w-0">
<div className="min-w-0 max-w-[33.3%]">
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-1">
<div>
<label htmlFor="templateID" className="sr-only">
Select template
</label>
@@ -293,7 +292,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
</div>
{permissions.updateTemplates && (
<div className="min-w-0 max-w-[33.3%]">
<div>
<label htmlFor="versionId" className="sr-only">
Template version
</label>
@@ -306,7 +305,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex-1 overflow-hidden min-w-0">
<label htmlFor="presetID" className="sr-only">
Preset
</label>
@@ -325,47 +324,40 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
<PromptSelectTrigger
id="presetID"
tooltip="Preset"
className="max-w-full [&_[data-slot=preset-name]]:truncate [&_[data-slot=preset-name]]:min-w-0 [&_[data-slot=preset-description]]:hidden"
className="w-full max-w-full [&_span]:flex [&_span]:items-center [&_span]:gap-2 [&_span]:min-w-0 [&_span]:overflow-hidden [&_span>span]:truncate [&_svg[data-slot='preset-description']]:hidden"
>
<SelectValue placeholder="Select a preset" />
</PromptSelectTrigger>
<SelectContent>
{presets?.toSorted(sortByDefault).map((preset) => (
<SelectItem value={preset.ID} key={preset.ID}>
<div className="flex items-center gap-2">
{preset.Icon && (
<img
data-slot="preset-icon"
src={preset.Icon}
alt={preset.Name}
className="size-icon-sm shrink-0"
/>
)}
<span
data-slot="preset-name"
className="truncate min-w-0"
>
{preset.Name}
</span>
{preset.Default && (
<Badge size="xs" className="shrink-0">
Default
</Badge>
)}
{preset.Description && (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
className="size-4"
data-slot="preset-description"
/>
</TooltipTrigger>
<TooltipContent>
{preset.Description}
</TooltipContent>
</Tooltip>
)}
</div>
<SelectItem
value={preset.ID}
key={preset.ID}
className="[&_span]:flex [&_span]:items-center [&_span]:gap-2"
>
{preset.Icon && (
<img
src={preset.Icon}
alt={preset.Name}
className="size-icon-sm flex-shrink-0"
/>
)}
<span>
{preset.Name} {preset.Default && "(Default)"}
</span>
{preset.Description && (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
className="size-4"
data-slot="preset-description"
/>
</TooltipTrigger>
<TooltipContent>
{preset.Description}
</TooltipContent>
</Tooltip>
)}
</SelectItem>
))}
</SelectContent>
@@ -48,10 +48,10 @@ export const TemplateVersionSelect: FC<TemplateVersionSelectProps> = ({
{versions.map((version) => {
return (
<SelectItem value={version.id} key={version.id}>
<span className="flex items-center gap-2 min-w-0">
<span className="truncate">{version.name}</span>
<span className="flex items-center gap-2">
{version.name}
{activeVersionId === version.id && (
<Badge size="xs" variant="green" className="shrink-0">
<Badge size="xs" variant="green">
Active
</Badge>
)}
@@ -1,5 +1,5 @@
import Link from "@mui/material/Link";
import type { AuditLog } from "api/typesGenerated";
import { Link } from "components/Link/Link";
import type { FC } from "react";
import { Link as RouterLink } from "react-router";
import { BuildAuditDescription } from "./BuildAuditDescription";
@@ -52,10 +52,8 @@ export const AuditLogDescription: FC<AuditLogDescriptionProps> = ({
<span>
{truncatedDescription}
{auditLog.resource_link ? (
<Link asChild showExternalIcon={false} className="text-base px-0">
<RouterLink to={auditLog.resource_link}>
<strong>{target}</strong>
</RouterLink>
<Link component={RouterLink} to={auditLog.resource_link}>
<strong>{target}</strong>
</Link>
) : (
<strong>{target}</strong>
@@ -72,10 +70,8 @@ function AppSessionAuditLogDescription({ auditLog }: AuditLogDescriptionProps) {
return (
<>
{connection_type} session to {workspace_owner}'s{" "}
<Link asChild showExternalIcon={false} className="text-base px-0">
<RouterLink to={`${auditLog.resource_link}`}>
<strong>{workspace_name}</strong>
</RouterLink>
<Link component={RouterLink} to={`${auditLog.resource_link}`}>
<strong>{workspace_name}</strong>
</Link>{" "}
workspace{" "}
<strong>{auditLog.action === "disconnect" ? "closed" : "opened"}</strong>
@@ -1,5 +1,5 @@
import Link from "@mui/material/Link";
import type { AuditLog } from "api/typesGenerated";
import { Link } from "components/Link/Link";
import { type FC, useMemo } from "react";
import { Link as RouterLink } from "react-router";
import { systemBuildReasons } from "utils/workspace";
@@ -38,10 +38,8 @@ export const BuildAuditDescription: FC<BuildAuditDescriptionProps> = ({
<span>
{user} <strong>{action}</strong> workspace{" "}
{auditLog.resource_link ? (
<Link asChild showExternalIcon={false} className="text-base px-0">
<RouterLink to={auditLog.resource_link}>
<strong>{workspaceName}</strong>
</RouterLink>
<Link component={RouterLink} to={auditLog.resource_link}>
<strong>{workspaceName}</strong>
</Link>
) : (
<strong>{workspaceName}</strong>
@@ -1,11 +1,10 @@
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
import Collapse from "@mui/material/Collapse";
import Link from "@mui/material/Link";
import type { AuditLog, BuildReason } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import {
Collapsible,
CollapsibleContent,
} from "components/Collapsible/Collapsible";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import { Link } from "components/Link/Link";
import { Stack } from "components/Stack/Stack";
import { StatusPill } from "components/StatusPill/StatusPill";
import { TableCell } from "components/Table/Table";
import { TimelineEntry } from "components/Timeline/TimelineEntry";
@@ -72,186 +71,246 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
data-testid={`audit-log-row-${auditLog.id}`}
clickable={shouldDisplayDiff}
>
<TableCell className="!p-0 border-0 border-b text-base">
<Collapsible open={isDiffOpen} onOpenChange={setIsDiffOpen}>
<div
className="flex flex-row items-center gap-4 py-4 px-8"
tabIndex={0}
role="button"
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter") {
toggle();
}
}}
<TableCell css={styles.auditLogCell}>
<Stack
direction="row"
alignItems="center"
css={styles.auditLogHeader}
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key === "Enter") {
toggle();
}
}}
>
<Stack
direction="row"
alignItems="center"
css={styles.auditLogHeaderInfo}
>
<div className="flex flex-row items-center gap-4 flex-1">
<div className="flex flex-row items-center gap-4 w-full">
{/*
* Session logs don't have an associated user to the log,
* so when it happens we display a default icon to represent non user actions
*/}
{auditLog.user ? (
<Avatar
fallback={auditLog.user.username}
src={auditLog.user.avatar_url}
/>
) : (
<Avatar>
<NetworkIcon className="h-full w-full p-1" />
</Avatar>
)}
<Stack direction="row" alignItems="center" css={styles.fullWidth}>
{/*
* Session logs don't have an associated user to the log,
* so when it happens we display a default icon to represent non user actions
*/}
{auditLog.user ? (
<Avatar
fallback={auditLog.user.username}
src={auditLog.user.avatar_url}
/>
) : (
<Avatar>
<NetworkIcon className="h-full w-full p-1" />
</Avatar>
)}
<div className="flex flex-row items-baseline justify-between w-full font-normal gap-4">
<div className="flex flex-row items-baseline gap-2">
<AuditLogDescription auditLog={auditLog} />
{auditLog.is_deleted && (
<span className="text-xs text-content-secondary">
(deleted)
</span>
)}
<span className="text-content-secondary text-xs">
{new Date(auditLog.time).toLocaleTimeString()}
</span>
</div>
<Stack
alignItems="baseline"
css={styles.fullWidth}
justifyContent="space-between"
direction="row"
>
<Stack
css={styles.auditLogSummary}
direction="row"
alignItems="baseline"
spacing={1}
>
<AuditLogDescription auditLog={auditLog} />
{auditLog.is_deleted && (
<span css={styles.deletedLabel}>(deleted)</span>
)}
<span css={styles.auditLogTime}>
{new Date(auditLog.time).toLocaleTimeString()}
</span>
</Stack>
<div className="flex flex-row items-center gap-4">
<StatusPill isHttpCode={true} code={auditLog.status_code} />
<Stack direction="row" alignItems="center">
<StatusPill isHttpCode={true} code={auditLog.status_code} />
{/* With multi-org, there is not enough space so show
{/* With multi-org, there is not enough space so show
everything in a tooltip. */}
{showOrgDetails ? (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="text-content-link" />
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="flex flex-col gap-2">
{auditLog.ip && (
{showOrgDetails ? (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
css={(theme) => ({
color: theme.palette.info.light,
})}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
<div css={styles.auditLogInfoTooltip}>
{auditLog.ip && (
<div>
<h4 css={styles.auditLogInfoHeader}>IP:</h4>
<div>{auditLog.ip}</div>
</div>
)}
{userAgent?.os.name && (
<div>
<h4 css={styles.auditLogInfoHeader}>OS:</h4>
<div>{userAgent.os.name}</div>
</div>
)}
{userAgent?.browser.name && (
<div>
<h4 css={styles.auditLogInfoHeader}>Browser:</h4>
<div>
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
IP:
</h4>
<div>{auditLog.ip}</div>
{userAgent.browser.name}{" "}
{userAgent.browser.version}
</div>
)}
{userAgent?.os.name && (
</div>
)}
{auditLog.organization && (
<div>
<h4 css={styles.auditLogInfoHeader}>
Organization:
</h4>
<Link
component={RouterLink}
to={`/organizations/${auditLog.organization.name}`}
>
{auditLog.organization.display_name ||
auditLog.organization.name}
</Link>
</div>
)}
{auditLog.additional_fields?.build_reason &&
auditLog.action === "start" && (
<div>
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
OS:
</h4>
<div>{userAgent.os.name}</div>
</div>
)}
{userAgent?.browser.name && (
<div>
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
Browser:
</h4>
<h4 css={styles.auditLogInfoHeader}>Reason:</h4>
<div>
{userAgent.browser.name}{" "}
{userAgent.browser.version}
{
buildReasonLabels[
auditLog.additional_fields
.build_reason as BuildReason
]
}
</div>
</div>
)}
{auditLog.organization && (
<div>
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
Organization:
</h4>
<Link
asChild
showExternalIcon={false}
className="px-0"
>
<RouterLink
to={`/organizations/${auditLog.organization.name}`}
>
{auditLog.organization.display_name ||
auditLog.organization.name}
</RouterLink>
</Link>
</div>
)}
{auditLog.additional_fields?.build_reason &&
auditLog.action === "start" && (
<div>
<h4 className="m-0 text-content-primary leading-normal font-semibold">
Reason:
</h4>
<div>
{
buildReasonLabels[
auditLog.additional_fields
.build_reason as BuildReason
]
}
</div>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
) : (
<div className="flex flex-row items-baseline gap-2">
{auditLog.ip && (
<span className="text-xs text-content-secondary block">
<span>IP: </span>
<strong>{auditLog.ip}</strong>
</span>
)}
{userAgent?.os.name && (
<span className="text-xs text-content-secondary block">
<span>OS: </span>
<strong>{userAgent.os.name}</strong>
</span>
)}
{userAgent?.browser.name && (
<span className="text-xs text-content-secondary block">
<span>Browser: </span>
</div>
</TooltipContent>
</Tooltip>
) : (
<Stack direction="row" spacing={1} alignItems="baseline">
{auditLog.ip && (
<span css={styles.auditLogInfo}>
<span>IP: </span>
<strong>{auditLog.ip}</strong>
</span>
)}
{userAgent?.os.name && (
<span css={styles.auditLogInfo}>
<span>OS: </span>
<strong>{userAgent.os.name}</strong>
</span>
)}
{userAgent?.browser.name && (
<span css={styles.auditLogInfo}>
<span>Browser: </span>
<strong>
{userAgent.browser.name} {userAgent.browser.version}
</strong>
</span>
)}
{auditLog.additional_fields?.build_reason &&
auditLog.action === "start" && (
<span css={styles.auditLogInfo}>
<span>Reason: </span>
<strong>
{userAgent.browser.name}{" "}
{userAgent.browser.version}
{
buildReasonLabels[
auditLog.additional_fields
.build_reason as BuildReason
]
}
</strong>
</span>
)}
{auditLog.additional_fields?.build_reason &&
auditLog.action === "start" && (
<span className="text-xs text-content-secondary block">
<span>Reason: </span>
<strong>
{
buildReasonLabels[
auditLog.additional_fields
.build_reason as BuildReason
]
}
</strong>
</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
</Stack>
)}
</Stack>
</Stack>
</Stack>
</Stack>
{shouldDisplayDiff ? (
<div>
<DropdownArrow close={isDiffOpen} />
</div>
) : (
<div className="ml-6" />
)}
</div>
{shouldDisplayDiff && (
<CollapsibleContent>
<AuditLogDiff diff={auditDiff} />
</CollapsibleContent>
{shouldDisplayDiff ? (
<div> {<DropdownArrow close={isDiffOpen} />}</div>
) : (
<div css={styles.columnWithoutDiff} />
)}
</Collapsible>
</Stack>
{shouldDisplayDiff && (
<Collapse in={isDiffOpen}>
<AuditLogDiff diff={auditDiff} />
</Collapse>
)}
</TableCell>
</TimelineEntry>
);
};
const styles = {
auditLogCell: {
padding: "0 !important",
border: 0,
},
auditLogHeader: {
padding: "16px 32px",
},
auditLogHeaderInfo: {
flex: 1,
},
auditLogSummary: (theme) => ({
...(theme.typography.body1 as CSSObject),
fontFamily: "inherit",
}),
auditLogTime: (theme) => ({
color: theme.palette.text.secondary,
fontSize: 12,
}),
auditLogInfo: (theme) => ({
...(theme.typography.body2 as CSSObject),
fontSize: 12,
fontFamily: "inherit",
color: theme.palette.text.secondary,
display: "block",
}),
auditLogInfoHeader: (theme) => ({
margin: 0,
color: theme.palette.text.primary,
fontSize: 14,
lineHeight: "150%",
fontWeight: 600,
}),
auditLogInfoTooltip: {
display: "flex",
flexDirection: "column",
gap: 8,
},
// offset the absence of the arrow icon on diff-less logs
columnWithoutDiff: {
marginLeft: "24px",
},
fullWidth: {
width: "100%",
},
deletedLabel: (theme) => ({
...(theme.typography.caption as CSSObject),
color: theme.palette.text.secondary,
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -86,14 +86,24 @@ export const LicenseCard: FC<LicenseCardProps> = ({
</span>
</Stack>
{license.claims.nbf && (
<Stack direction="column" spacing={0} alignItems="center">
<Stack
direction="column"
spacing={0}
alignItems="center"
width="134px" // standardize width of date column
>
<span css={styles.secondaryMaincolor}>Valid From</span>
<span css={styles.licenseExpires} className="license-valid-from">
{dayjs.unix(license.claims.nbf).format("MMMM D, YYYY")}
</span>
</Stack>
)}
<Stack direction="column" spacing={0} alignItems="center">
<Stack
direction="column"
spacing={0}
alignItems="center"
width="134px" // standardize width of date column
>
{dayjs(license.claims.license_expires * 1000).isBefore(dayjs()) ? (
<Pill css={styles.expiredBadge} type="error">
Expired
+6 -3
View File
@@ -10,6 +10,7 @@ import {
type ComponentProps,
cloneElement,
type FC,
forwardRef,
type HTMLAttributes,
type ReactElement,
} from "react";
@@ -154,15 +155,17 @@ export const SectionLabel: FC<HTMLAttributes<HTMLHeadingElement>> = (props) => {
);
};
type PillProps = React.ComponentPropsWithRef<"div"> & {
type PillProps = HTMLAttributes<HTMLDivElement> & {
icon: ReactElement<HTMLAttributes<HTMLElement>>;
};
export const Pill: React.FC<PillProps> = ({ icon, children, ...divProps }) => {
export const Pill = forwardRef<HTMLDivElement, PillProps>((props, ref) => {
const theme = useTheme();
const { icon, children, ...divProps } = props;
return (
<div
ref={ref}
css={{
display: "inline-flex",
alignItems: "center",
@@ -181,7 +184,7 @@ export const Pill: React.FC<PillProps> = ({ icon, children, ...divProps }) => {
{children}
</div>
);
};
});
type BooleanPillProps = Omit<ComponentProps<typeof Pill>, "icon" | "value"> & {
value: boolean | null;
@@ -21,7 +21,7 @@ export const TemplateScheduleAutostart: FC<TemplateScheduleAutostartProps> = ({
onChange,
}) => {
return (
<Stack alignItems="start" spacing={1}>
<Stack width="100%" alignItems="start" spacing={1}>
<Stack
direction="row"
spacing={0}
@@ -96,18 +96,11 @@ const WorkspaceParametersPageExperimental: FC = () => {
return;
}
// Skip stale responses. If we've already sent a newer request,
// this response contains outdated parameter values that would
// overwrite the user's more recent input.
if (response.id < wsResponseId.current) {
return;
}
setLatestResponse(response);
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
sendInitialParameters();
}
setLatestResponse(response);
});
useEffect(() => {
@@ -204,7 +197,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
if (
latestBuildParametersLoading ||
(!latestResponse && !wsError) ||
!latestResponse ||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
) {
return <Loader />;
@@ -251,7 +244,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={latestResponse?.diagnostics ?? []}
diagnostics={latestResponse.diagnostics}
isSubmitting={updateParameters.isPending}
onSubmit={handleSubmit}
onCancel={() =>
@@ -9,7 +9,6 @@ import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import { useDebouncedFunction } from "hooks/debounce";
import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters";
import {
DynamicParameter,
@@ -17,7 +16,6 @@ import {
useValidationSchemaForDynamicParameters,
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import type { FC } from "react";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import type { AutofillBuildParameter } from "utils/richParameters";
@@ -69,23 +67,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
workspace.template_require_active_version &&
!canChangeVersions;
// Debounce websocket sends to avoid stale responses overwriting
// the form while the user is still typing.
const { debounced: sendDynamicParamsRequest } = useDebouncedFunction(
(parameter: PreviewParameter, value: string) => {
const formInputs: Record<string, string> = {};
const formParameters = form.values.rich_parameter_values ?? [];
for (const param of formParameters) {
if (param?.name && param?.value) {
formInputs[param.name] = param.value;
}
}
formInputs[parameter.name] = value;
sendMessage(formInputs);
},
500,
);
const handleChange = async (
parameter: PreviewParameter,
parameterField: string,
@@ -98,27 +79,29 @@ export const WorkspaceParametersPageViewExperimental: FC<
sendDynamicParamsRequest(parameter, value);
};
const sendDynamicParamsRequest = (
parameter: PreviewParameter,
value: string,
) => {
const formInputs: Record<string, string> = {};
const parameters = form.values.rich_parameter_values ?? [];
for (const param of parameters) {
if (param?.name && param?.value) {
formInputs[param.name] = param.value;
}
}
formInputs[parameter.name] = value;
sendMessage(formInputs);
};
useSyncFormParameters({
parameters,
formValues: form.values.rich_parameter_values ?? [],
setFieldValue: form.setFieldValue,
});
// True when the form holds values the backend hasn't evaluated
// yet (debounce pending or WS round-trip in flight).
const hasUnsyncedParameters = (form.values.rich_parameter_values ?? []).some(
(formParam) => {
const responseParam = parameters.find((p) => p.name === formParam.name);
if (!responseParam) {
return true;
}
const responseValue = responseParam.value.valid
? responseParam.value.value
: "";
return formParam.value !== responseValue;
},
);
const hasIncompatibleParameters = parameters.some((parameter) => {
if (!parameter.mutable && parameter.diagnostics.length > 0) {
return true;
@@ -172,12 +155,12 @@ export const WorkspaceParametersPageViewExperimental: FC<
{diagnostics.map((diagnostic, index) => (
<div
key={`diagnostic-${diagnostic.summary}-${index}`}
className={cn(
"text-xs flex flex-col rounded-md border px-4 pb-3 border-solid",
diagnostic.severity === "error"
? " text-content-destructive border-border-destructive"
: " text-content-warning border-border-warning",
)}
className={`text-xs flex flex-col rounded-md border px-4 pb-3 border-solid
${
diagnostic.severity === "error"
? " text-content-destructive border-border-destructive"
: " text-content-warning border-border-warning"
}`}
>
<div className="flex items-center m-0">
<p className="font-medium">{diagnostic.summary}</p>
@@ -265,7 +248,6 @@ export const WorkspaceParametersPageViewExperimental: FC<
disabled={
isSubmitting ||
disabled ||
hasUnsyncedParameters ||
diagnostics.some(
(diagnostic) => diagnostic.severity === "error",
) ||
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB