@checkstack/ui 1.1.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/src/components/DynamicForm/DynamicForm.tsx +17 -2
- package/src/components/DynamicForm/FormField.tsx +149 -41
- package/src/components/DynamicForm/types.ts +1 -0
- package/src/components/DynamicForm/utils.test.ts +69 -0
- package/src/components/DynamicForm/utils.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @checkstack/ui
|
|
2
2
|
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 23c80bc: ### Jira Data Center Support
|
|
8
|
+
|
|
9
|
+
Added support for on-premise Jira Data Center installations alongside existing Jira Cloud support:
|
|
10
|
+
|
|
11
|
+
- **Authentication mode switching**: New `authMode` field (`cloud` | `datacenter`) on connection configuration. Cloud uses Basic Auth (email + API token), Data Center uses Bearer Auth (Personal Access Token).
|
|
12
|
+
- **API version routing**: Automatically selects REST API v3 for Cloud and v2 for Data Center.
|
|
13
|
+
- **Description format**: Cloud uses Atlassian Document Format (ADF), Data Center uses plain text.
|
|
14
|
+
- **Connection schema v2**: Backward-compatible — defaults to `cloud` mode for existing connections.
|
|
15
|
+
|
|
16
|
+
### DynamicForm `x-hidden-when` Conditional Visibility
|
|
17
|
+
|
|
18
|
+
New generic platform feature for conditionally hiding form fields based on sibling field values:
|
|
19
|
+
|
|
20
|
+
- Added `x-hidden-when` metadata extension to `ConfigMeta` and `JsonSchemaProperty`.
|
|
21
|
+
- DynamicForm automatically hides fields and skips their validation when conditions match.
|
|
22
|
+
- Used by Jira integration to hide the email field when `authMode` is `datacenter`.
|
|
23
|
+
|
|
24
|
+
## 1.1.5
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- 95aa716: Fix LDAP CA certificate input: The custom CA certificate field was rendered as a single-line password input, which stripped newlines from PEM certificates and caused TLS connection failures ("Failed to connect"). The field now renders as a multi-line secret textarea that properly preserves PEM format while still encrypting the value in storage.
|
|
29
|
+
|
|
3
30
|
## 1.1.4
|
|
4
31
|
|
|
5
32
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import React from "react";
|
|
|
3
3
|
import { EmptyState } from "../../index";
|
|
4
4
|
|
|
5
5
|
import type { DynamicFormProps } from "./types";
|
|
6
|
-
import { extractDefaults, isValueEmpty } from "./utils";
|
|
6
|
+
import { extractDefaults, isValueEmpty, isFieldHiddenByCondition } from "./utils";
|
|
7
7
|
import { FormField } from "./FormField";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -50,6 +50,13 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
|
|
|
50
50
|
// Skip hidden fields - they are auto-populated
|
|
51
51
|
if (propSchema["x-hidden"]) continue;
|
|
52
52
|
|
|
53
|
+
// Skip conditionally hidden fields - they are not visible
|
|
54
|
+
if (
|
|
55
|
+
propSchema["x-hidden-when"] &&
|
|
56
|
+
isFieldHiddenByCondition(propSchema["x-hidden-when"], value)
|
|
57
|
+
)
|
|
58
|
+
continue;
|
|
59
|
+
|
|
53
60
|
if (isValueEmpty(value[key], propSchema)) {
|
|
54
61
|
isValid = false;
|
|
55
62
|
break;
|
|
@@ -79,7 +86,15 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({
|
|
|
79
86
|
return (
|
|
80
87
|
<div className="space-y-6">
|
|
81
88
|
{Object.entries(schema.properties)
|
|
82
|
-
.filter(([, propSchema]) =>
|
|
89
|
+
.filter(([, propSchema]) => {
|
|
90
|
+
if (propSchema["x-hidden"]) return false;
|
|
91
|
+
if (
|
|
92
|
+
propSchema["x-hidden-when"] &&
|
|
93
|
+
isFieldHiddenByCondition(propSchema["x-hidden-when"], value)
|
|
94
|
+
)
|
|
95
|
+
return false;
|
|
96
|
+
return true;
|
|
97
|
+
})
|
|
83
98
|
.map(([key, propSchema]) => {
|
|
84
99
|
const isRequired = schema.required?.includes(key);
|
|
85
100
|
const label = key.charAt(0).toUpperCase() + key.slice(1);
|
|
@@ -140,6 +140,20 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
140
140
|
propSchema as JsonSchemaProperty & { "x-secret"?: boolean }
|
|
141
141
|
)["x-secret"];
|
|
142
142
|
|
|
143
|
+
// Secret textarea fields (e.g., PEM certificates)
|
|
144
|
+
if (isTextarea && isSecret) {
|
|
145
|
+
return (
|
|
146
|
+
<SecretTextareaField
|
|
147
|
+
id={id}
|
|
148
|
+
label={label}
|
|
149
|
+
description={cleanDesc}
|
|
150
|
+
value={value as string}
|
|
151
|
+
isRequired={isRequired}
|
|
152
|
+
onChange={onChange}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
143
157
|
// Textarea fields
|
|
144
158
|
if (isTextarea) {
|
|
145
159
|
return (
|
|
@@ -577,6 +591,56 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|
|
577
591
|
return <></>;
|
|
578
592
|
};
|
|
579
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Shared visibility toggle button for secret fields.
|
|
596
|
+
*/
|
|
597
|
+
const VisibilityToggle: React.FC<{
|
|
598
|
+
visible: boolean;
|
|
599
|
+
onToggle: () => void;
|
|
600
|
+
}> = ({ visible, onToggle }) => (
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
onClick={onToggle}
|
|
604
|
+
className="absolute right-2 top-3 text-muted-foreground hover:text-foreground"
|
|
605
|
+
>
|
|
606
|
+
{visible ? (
|
|
607
|
+
<svg
|
|
608
|
+
className="h-5 w-5"
|
|
609
|
+
fill="none"
|
|
610
|
+
stroke="currentColor"
|
|
611
|
+
viewBox="0 0 24 24"
|
|
612
|
+
>
|
|
613
|
+
<path
|
|
614
|
+
strokeLinecap="round"
|
|
615
|
+
strokeLinejoin="round"
|
|
616
|
+
strokeWidth={2}
|
|
617
|
+
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
|
618
|
+
/>
|
|
619
|
+
</svg>
|
|
620
|
+
) : (
|
|
621
|
+
<svg
|
|
622
|
+
className="h-5 w-5"
|
|
623
|
+
fill="none"
|
|
624
|
+
stroke="currentColor"
|
|
625
|
+
viewBox="0 0 24 24"
|
|
626
|
+
>
|
|
627
|
+
<path
|
|
628
|
+
strokeLinecap="round"
|
|
629
|
+
strokeLinejoin="round"
|
|
630
|
+
strokeWidth={2}
|
|
631
|
+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
632
|
+
/>
|
|
633
|
+
<path
|
|
634
|
+
strokeLinecap="round"
|
|
635
|
+
strokeLinejoin="round"
|
|
636
|
+
strokeWidth={2}
|
|
637
|
+
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
638
|
+
/>
|
|
639
|
+
</svg>
|
|
640
|
+
)}
|
|
641
|
+
</button>
|
|
642
|
+
);
|
|
643
|
+
|
|
580
644
|
/**
|
|
581
645
|
* Secret field component with password visibility toggle.
|
|
582
646
|
* Extracted to keep hooks at component top level.
|
|
@@ -612,47 +676,91 @@ const SecretField: React.FC<{
|
|
|
612
676
|
placeholder={hasExistingValue ? "••••••••" : "Enter secret value"}
|
|
613
677
|
className="pr-10"
|
|
614
678
|
/>
|
|
615
|
-
<
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
679
|
+
<VisibilityToggle
|
|
680
|
+
visible={showPassword}
|
|
681
|
+
onToggle={() => setShowPassword(!showPassword)}
|
|
682
|
+
/>
|
|
683
|
+
</div>
|
|
684
|
+
{hasExistingValue && currentValue === "" && (
|
|
685
|
+
<p className="text-xs text-muted-foreground">
|
|
686
|
+
Leave empty to keep existing value
|
|
687
|
+
</p>
|
|
688
|
+
)}
|
|
689
|
+
</div>
|
|
690
|
+
);
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Secret textarea field for multi-line secrets (e.g., PEM certificates).
|
|
695
|
+
* Renders a textarea with a visibility toggle and secret-like behavior.
|
|
696
|
+
*/
|
|
697
|
+
const SecretTextareaField: React.FC<{
|
|
698
|
+
id: string;
|
|
699
|
+
label: string;
|
|
700
|
+
description?: string;
|
|
701
|
+
value: string;
|
|
702
|
+
isRequired?: boolean;
|
|
703
|
+
onChange: (val: unknown) => void;
|
|
704
|
+
}> = ({ id, label, description, value, isRequired, onChange }) => {
|
|
705
|
+
const [showContent, setShowContent] = React.useState(false);
|
|
706
|
+
const currentValue = value || "";
|
|
707
|
+
const hasExistingValue = currentValue.length > 0;
|
|
708
|
+
|
|
709
|
+
return (
|
|
710
|
+
<div className="space-y-2">
|
|
711
|
+
<div>
|
|
712
|
+
<Label htmlFor={id}>
|
|
713
|
+
{label} {isRequired && "*"}
|
|
714
|
+
</Label>
|
|
715
|
+
{description && (
|
|
716
|
+
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
|
717
|
+
)}
|
|
718
|
+
</div>
|
|
719
|
+
<div className="relative">
|
|
720
|
+
{showContent ? (
|
|
721
|
+
<Textarea
|
|
722
|
+
id={id}
|
|
723
|
+
value={currentValue}
|
|
724
|
+
onChange={(e) => onChange(e.target.value)}
|
|
725
|
+
placeholder={
|
|
726
|
+
hasExistingValue ? "Leave empty to keep existing value" : "Paste content here"
|
|
727
|
+
}
|
|
728
|
+
rows={5}
|
|
729
|
+
className="pr-10 font-mono text-xs"
|
|
730
|
+
/>
|
|
731
|
+
) : (
|
|
732
|
+
<Textarea
|
|
733
|
+
id={id}
|
|
734
|
+
value={currentValue ? "••••••••••••••••••••" : ""}
|
|
735
|
+
onChange={(e) => {
|
|
736
|
+
// If user types/pastes into masked field, switch to visible mode
|
|
737
|
+
const newVal = e.target.value.replaceAll("•", "");
|
|
738
|
+
if (newVal) {
|
|
739
|
+
setShowContent(true);
|
|
740
|
+
onChange(newVal);
|
|
741
|
+
}
|
|
742
|
+
}}
|
|
743
|
+
onPaste={(e) => {
|
|
744
|
+
// On paste, switch to visible mode and use pasted content
|
|
745
|
+
e.preventDefault();
|
|
746
|
+
const pastedText = e.clipboardData.getData("text");
|
|
747
|
+
if (pastedText) {
|
|
748
|
+
setShowContent(true);
|
|
749
|
+
onChange(pastedText);
|
|
750
|
+
}
|
|
751
|
+
}}
|
|
752
|
+
placeholder={
|
|
753
|
+
hasExistingValue ? "Leave empty to keep existing value" : "Paste content here"
|
|
754
|
+
}
|
|
755
|
+
rows={3}
|
|
756
|
+
className="pr-10"
|
|
757
|
+
readOnly={!!currentValue}
|
|
758
|
+
/>
|
|
759
|
+
)}
|
|
760
|
+
<VisibilityToggle
|
|
761
|
+
visible={showContent}
|
|
762
|
+
onToggle={() => setShowContent(!showContent)}
|
|
763
|
+
/>
|
|
656
764
|
</div>
|
|
657
765
|
{hasExistingValue && currentValue === "" && (
|
|
658
766
|
<p className="text-xs text-muted-foreground">
|
|
@@ -21,6 +21,7 @@ export interface JsonSchemaProperty extends JsonSchemaPropertyCore<JsonSchemaPro
|
|
|
21
21
|
"x-hidden"?: boolean; // Field should be hidden in form (auto-populated)
|
|
22
22
|
"x-searchable"?: boolean; // Shows search input for filtering dropdown options
|
|
23
23
|
"x-editor-types"?: EditorType[]; // Available editor types for multi-type input
|
|
24
|
+
"x-hidden-when"?: Record<string, string[]>; // Conditionally hide based on sibling field values
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Option returned by an options resolver */
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
extractDefaults,
|
|
6
6
|
getCleanDescription,
|
|
7
7
|
isValueEmpty,
|
|
8
|
+
isFieldHiddenByCondition,
|
|
8
9
|
NONE_SENTINEL,
|
|
9
10
|
parseSelectValue,
|
|
10
11
|
serializeFormData,
|
|
@@ -398,3 +399,71 @@ describe("detectEditorType", () => {
|
|
|
398
399
|
});
|
|
399
400
|
});
|
|
400
401
|
});
|
|
402
|
+
|
|
403
|
+
// =============================================================================
|
|
404
|
+
// Conditional Visibility Tests
|
|
405
|
+
// =============================================================================
|
|
406
|
+
|
|
407
|
+
describe("isFieldHiddenByCondition", () => {
|
|
408
|
+
it("returns true when sibling value matches a value in the array", () => {
|
|
409
|
+
const conditions = { authMode: ["datacenter"] };
|
|
410
|
+
const formValues = { authMode: "datacenter" };
|
|
411
|
+
expect(isFieldHiddenByCondition(conditions, formValues)).toBe(true);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("returns true when sibling value matches any value in the array", () => {
|
|
415
|
+
const conditions = { authMode: ["datacenter", "server"] };
|
|
416
|
+
const formValues = { authMode: "server" };
|
|
417
|
+
expect(isFieldHiddenByCondition(conditions, formValues)).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("returns false when sibling value does not match", () => {
|
|
421
|
+
const conditions = { authMode: ["datacenter"] };
|
|
422
|
+
const formValues = { authMode: "cloud" };
|
|
423
|
+
expect(isFieldHiddenByCondition(conditions, formValues)).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("returns false when sibling field is missing (coerces to empty string)", () => {
|
|
427
|
+
const conditions = { authMode: ["datacenter"] };
|
|
428
|
+
const formValues = {};
|
|
429
|
+
expect(isFieldHiddenByCondition(conditions, formValues)).toBe(false);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("returns true when sibling field is missing and empty string is in the value list", () => {
|
|
433
|
+
const conditions = { authMode: [""] };
|
|
434
|
+
const formValues = {};
|
|
435
|
+
expect(isFieldHiddenByCondition(conditions, formValues)).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("handles multiple conditions with OR semantics (any match hides)", () => {
|
|
439
|
+
const conditions = {
|
|
440
|
+
authMode: ["datacenter"],
|
|
441
|
+
environment: ["staging"],
|
|
442
|
+
};
|
|
443
|
+
// Only authMode matches
|
|
444
|
+
expect(
|
|
445
|
+
isFieldHiddenByCondition(conditions, {
|
|
446
|
+
authMode: "datacenter",
|
|
447
|
+
environment: "production",
|
|
448
|
+
}),
|
|
449
|
+
).toBe(true);
|
|
450
|
+
// Only environment matches
|
|
451
|
+
expect(
|
|
452
|
+
isFieldHiddenByCondition(conditions, {
|
|
453
|
+
authMode: "cloud",
|
|
454
|
+
environment: "staging",
|
|
455
|
+
}),
|
|
456
|
+
).toBe(true);
|
|
457
|
+
// Neither matches
|
|
458
|
+
expect(
|
|
459
|
+
isFieldHiddenByCondition(conditions, {
|
|
460
|
+
authMode: "cloud",
|
|
461
|
+
environment: "production",
|
|
462
|
+
}),
|
|
463
|
+
).toBe(false);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("returns false for empty conditions object", () => {
|
|
467
|
+
expect(isFieldHiddenByCondition({}, { authMode: "cloud" })).toBe(false);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
@@ -72,6 +72,22 @@ export function isValueEmpty(
|
|
|
72
72
|
/** Sentinel value used to represent "None" selection in Select components */
|
|
73
73
|
export const NONE_SENTINEL = "__none__";
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Evaluate x-hidden-when conditions against current form values.
|
|
77
|
+
* Returns true if the field should be hidden.
|
|
78
|
+
*
|
|
79
|
+
* Each condition maps a sibling field name to values that trigger hiding.
|
|
80
|
+
* The field is hidden if ANY condition matches (OR semantics).
|
|
81
|
+
*/
|
|
82
|
+
export function isFieldHiddenByCondition(
|
|
83
|
+
conditions: Record<string, string[]>,
|
|
84
|
+
formValues: Record<string, unknown>,
|
|
85
|
+
): boolean {
|
|
86
|
+
return Object.entries(conditions).some(([field, values]) =>
|
|
87
|
+
values.includes(String(formValues[field] ?? "")),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
/**
|
|
76
92
|
* Converts a select value to the actual form value.
|
|
77
93
|
* Handles the "None" sentinel value by returning undefined.
|