Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eee13c42a4 | |||
| 65b48c0f84 | |||
| 30cdf29e52 | |||
| b1d2bb6d71 | |||
| 94bad2a956 | |||
| 111714c7ed | |||
| 1f9c516c5c | |||
| 3645c65bb2 | |||
| d3d2d2fb1e | |||
| 086fb1f5d5 | |||
| a73a535a5b | |||
| 96e01c3018 | |||
| 6b10a0359b | |||
| b62583ad4b | |||
| 3d6727a2cb | |||
| b163962a14 | |||
| 9aca4ea27c | |||
| b0c10131ea | |||
| c8c7e13e96 | |||
| 249b7ea38e | |||
| 1333096e25 | |||
| 54bc9324dd | |||
| 109e5f2b19 | |||
| ee176b4207 | |||
| 7e1e16be33 | |||
| 5cfe8082ce | |||
| 6b7f672834 | |||
| c55f6252a1 | |||
| 842553b677 | |||
| 05a771ba77 | |||
| 70a0d42e65 | |||
| 6b1d73b466 | |||
| d7b9596145 | |||
| 7a0aa1a40a | |||
| 4d8ea43e11 | |||
| 6fddae98f6 | |||
| e33fbb6087 | |||
| 2337393e13 | |||
| d7357a1b0a | |||
| afbf1af29c | |||
| 1d834c747c | |||
| a80edec752 | |||
| 2a6473e8c6 | |||
| 1f9c0b9b7f | |||
| 5494afabd8 | |||
| 07c6e86a50 | |||
| b543821a1c | |||
| e8b7045a9b | |||
| 2571089528 | |||
| 1fb733fe1e |
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
-3
@@ -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');
|
||||
Vendored
-35
@@ -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;
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Vendored
-1
@@ -36,7 +36,6 @@ declare module "@emoji-mart/react" {
|
||||
emojiButtonSize?: number;
|
||||
emojiSize?: number;
|
||||
emojiVersion?: string;
|
||||
getSpritesheetURL?: (set: string) => string;
|
||||
onEmojiSelect: (emoji: EmojiData) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+4
-11
@@ -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={() =>
|
||||
|
||||
+23
-41
@@ -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 |
Reference in New Issue
Block a user