diff --git a/docs/README.md b/docs/README.md index 42cfee6..026f423 100644 --- a/docs/README.md +++ b/docs/README.md @@ -112,6 +112,11 @@ This is the complete list of the available commands provided by the CLI. | [configcat flag-v2 targeting percentage update](configcat-flag-v2-targeting-percentage-update.md) | Update or add the last percentage-only targeting rule | | [configcat flag-v2 targeting percentage clear](configcat-flag-v2-targeting-percentage-clear.md) | Delete the last percentage-only rule | | [configcat flag-v2 targeting percentage attribute](configcat-flag-v2-targeting-percentage-attribute.md) | Set the percentage evaluation attribute | +| [configcat flag-v2 variation](configcat-flag-v2-variation.md) | Manage Predefined Variations | +| [configcat flag-v2 variation create](configcat-flag-v2-variation-create.md) | Create a Predefined Variation | +| [configcat flag-v2 variation update](configcat-flag-v2-variation-update.md) | Update a Predefined Variation | +| [configcat flag-v2 variation rm](configcat-flag-v2-variation-rm.md) | Delete a Predefined Variation | +| [configcat flag-v2 variation ls](configcat-flag-v2-variation-ls.md) | List the Predefined Variations of a Feature Flag or Setting | ### configcat segment | Command | Description | | ------ | ----------- | diff --git a/docs/configcat-flag-create.md b/docs/configcat-flag-create.md index dc862b0..ff26dc0 100644 --- a/docs/configcat-flag-create.md +++ b/docs/configcat-flag-create.md @@ -19,6 +19,7 @@ configcat flag create -c -n "My awesome flag" -k myAwesomeFlag -t bo | `--hint`, `-H` | Hint of the new Flag or Setting | | `--init-value`, `-iv` | Initial value for each Environment | | `--init-values-per-environment`, `-ive` | Initial value for specific Environments. Format: `:` | +| `--predefined-variations`, `-pv` | Predefined variations of the Feature Flag or Setting. Format: `` or `:`. | | `--type`, `-t` | Type of the new Flag or Setting

*Possible values*: `boolean`, `double`, `int`, `string` | | `--tag-ids`, `-g` | Tags to attach | | `--verbose`, `-v`, `/v` | Print detailed execution information | diff --git a/docs/configcat-flag-v2-create.md b/docs/configcat-flag-v2-create.md index 1333034..54a429c 100644 --- a/docs/configcat-flag-v2-create.md +++ b/docs/configcat-flag-v2-create.md @@ -19,6 +19,7 @@ configcat flag create -c -n "My awesome flag" -k myAwesomeFlag -t bo | `--hint`, `-H` | Hint of the new Flag or Setting | | `--init-value`, `-iv` | Initial value for each Environment | | `--init-values-per-environment`, `-ive` | Initial value for specific Environments. Format: `:` | +| `--predefined-variations`, `-pv` | Predefined variations of the Feature Flag or Setting. Format: `` or `:`. | | `--type`, `-t` | Type of the new Flag or Setting

*Possible values*: `boolean`, `double`, `int`, `string` | | `--tag-ids`, `-g` | Tags to attach | | `--verbose`, `-v`, `/v` | Print detailed execution information | diff --git a/docs/configcat-flag-v2-targeting-condition-add-prerequisite.md b/docs/configcat-flag-v2-targeting-condition-add-prerequisite.md index 20a26a7..f3b7c32 100644 --- a/docs/configcat-flag-v2-targeting-condition-add-prerequisite.md +++ b/docs/configcat-flag-v2-targeting-condition-add-prerequisite.md @@ -18,7 +18,7 @@ configcat flag-v2 targeting condition add prerequisite -i -e
*Possible values*: `doesNotEqual`, `equals` | | `--prerequisite-id`, `-pi` | ID of the prerequisite flag that the condition is based on | -| `--prerequisite-value`, `-pv` | The evaluated value of the prerequisite flag is compared to. It must respect the prerequisite flag's setting type | +| `--prerequisite-value`, `-pv` | The evaluated value of the prerequisite flag is compared to. It must respect the prerequisite flag's setting type. When the prerequisite flag has Predefined Variations, it must be either the Variation's ID or name | | `--reason`, `-r` | The reason note for the Audit Log if the Product's 'Config changes require a reason' preference is turned on | | `--verbose`, `-v`, `/v` | Print detailed execution information | | `--non-interactive`, `-ni` | Turn off progress rendering and interactive features | diff --git a/docs/configcat-flag-v2-targeting-rule-create-prerequisite.md b/docs/configcat-flag-v2-targeting-rule-create-prerequisite.md index d3cb9ee..386694f 100644 --- a/docs/configcat-flag-v2-targeting-rule-create-prerequisite.md +++ b/docs/configcat-flag-v2-targeting-rule-create-prerequisite.md @@ -17,8 +17,8 @@ configcat flag-v2 targeting rule create prerequisite -i -e
*Possible values*: `doesNotEqual`, `equals` | | `--prerequisite-id`, `-pi` | ID of the prerequisite flag that the condition is based on | -| `--prerequisite-value`, `-pv` | The evaluated value of the prerequisite flag is compared to. It must respect the prerequisite flag's setting type | -| `--served-value`, `-sv` | The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type | +| `--prerequisite-value`, `-pv` | The evaluated value of the prerequisite flag is compared to. It must respect the prerequisite flag's setting type. When the prerequisite flag has Predefined Variations, it either must be the Variation's ID or name | +| `--served-value`, `-sv` | The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type. When the flag has Predefined Variations, it must be either the Variation's ID or name | | `-po`, `--percentage-options` | Format: `:`, e.g., `30:true 70:false` | | `--reason`, `-r` | The reason note for the Audit Log if the Product's 'Config changes require a reason' preference is turned on | | `--verbose`, `-v`, `/v` | Print detailed execution information | diff --git a/docs/configcat-flag-v2-targeting-rule-create-segment.md b/docs/configcat-flag-v2-targeting-rule-create-segment.md index 6dd2b61..1059311 100644 --- a/docs/configcat-flag-v2-targeting-rule-create-segment.md +++ b/docs/configcat-flag-v2-targeting-rule-create-segment.md @@ -17,7 +17,7 @@ configcat flag-v2 targeting rule create segment -i -e | `--environment-id`, `-e` | ID of the Environment where the rule must be created | | `--comparator`, `-c` | The operator which defines the expected result of the evaluation of the segment

*Possible values*: `isIn`, `isNotIn` | | `--segment-id`, `-si` | ID of the segment that the condition is based on | -| `--served-value`, `-sv` | The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type | +| `--served-value`, `-sv` | The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type. When the flag has Predefined Variations, it must be either the Variation's ID or name | | `-po`, `--percentage-options` | Format: `:`, e.g., `30:true 70:false` | | `--reason`, `-r` | The reason note for the Audit Log if the Product's 'Config changes require a reason' preference is turned on | | `--verbose`, `-v`, `/v` | Print detailed execution information | diff --git a/docs/configcat-flag-v2-targeting-rule-create-user.md b/docs/configcat-flag-v2-targeting-rule-create-user.md index ac7faa2..59f43c1 100644 --- a/docs/configcat-flag-v2-targeting-rule-create-user.md +++ b/docs/configcat-flag-v2-targeting-rule-create-user.md @@ -18,7 +18,7 @@ configcat flag-v2 targeting rule create user -i -e -a | `--attribute`, `-a` | The User Object attribute that the condition is based on | | `--comparator`, `-c` | The operator which defines the relation between the comparison attribute and the comparison value

*Possible values*: `arrayContainsAnyOf`, `arrayDoesNotContainAnyOf`, `containsAnyOf`, `dateTimeAfter`, `dateTimeBefore`, `doesNotContainAnyOf`, `isNotOneOf`, `isOneOf`, `numberDoesNotEqual`, `numberEquals`, `numberGreater`, `numberLess`, `numberLessOrEquals`, `semVerGreater`, `semVerGreaterOrEquals`, `semVerIsNotOneOf`, `semVerIsOneOf`, `semVerLess`, `semVerLessOrEquals`, `sensitiveArrayContainsAnyOf`, `sensitiveArrayDoesNotContainAnyOf`, `sensitiveIsNotOneOf`, `sensitiveIsOneOf`, `sensitiveTextDoesNotEqual`, `sensitiveTextEndsWithAnyOf`, `sensitiveTextEquals`, `sensitiveTextNotEndsWithAnyOf`, `sensitiveTextNotStartsWithAnyOf`, `sensitiveTextStartsWithAnyOf`, `textDoesNotEqual`, `textEndsWithAnyOf`, `textEquals`, `textNotEndsWithAnyOf`, `textNotStartsWithAnyOf`, `textStartsWithAnyOf` | | `--comparison-value`, `-cv` | The value that the User Object attribute is compared to. Can be a double, string, or value-hint list in the format: `:` | -| `--served-value`, `-sv` | The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type | +| `--served-value`, `-sv` | The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type. When the flag has Predefined Variations, it must be either the Variation's ID or name | | `-po`, `--percentage-options` | Format: `:`, e.g., `30:true 70:false` | | `--reason`, `-r` | The reason note for the Audit Log if the Product's 'Config changes require a reason' preference is turned on | | `--verbose`, `-v`, `/v` | Print detailed execution information | diff --git a/docs/configcat-flag-v2-targeting-rule-update-served-value.md b/docs/configcat-flag-v2-targeting-rule-update-served-value.md index 1d60f4b..f7ba50e 100644 --- a/docs/configcat-flag-v2-targeting-rule-update-served-value.md +++ b/docs/configcat-flag-v2-targeting-rule-update-served-value.md @@ -16,7 +16,7 @@ configcat flag-v2 targeting rule usv -i -e -rp 1 -sv | `--flag-id`, `-i`, `--setting-id` | ID of the Feature Flag or Setting | | `--environment-id`, `-e` | ID of the Environment where the rule should be moved | | `--rule-position`, `-rp` | The position of the targeting rule | -| `--served-value`, `-sv` | The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type | +| `--served-value`, `-sv` | The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type. When the flag has Predefined Variations, it must be either the Variation's ID or name | | `-po`, `--percentage-options` | Format: `:`, e.g., `30:true 70:false` | | `--reason`, `-r` | The reason note for the Audit Log if the Product's 'Config changes require a reason' preference is turned on | | `--verbose`, `-v`, `/v` | Print detailed execution information | diff --git a/docs/configcat-flag-v2-variation-create.md b/docs/configcat-flag-v2-variation-create.md new file mode 100644 index 0000000..157a774 --- /dev/null +++ b/docs/configcat-flag-v2-variation-create.md @@ -0,0 +1,26 @@ +# configcat flag-v2 variation create +Create a Predefined Variation +## Aliases +`cr` +## Usage +``` +configcat flag-v2 variation create [options] +``` +## Example +``` +configcat flag-v2 variation create -i -name -sv +``` +## Options +| Option | Description | +| ------ | ----------- | +| `--flag-id`, `-i`, `--setting-id` | ID of the Feature Flag or Setting | +| `--name`, `-n` | Name of the new Predefined Variation | +| `--hint`, `-H` | Hint of the new Predefined Variation | +| `--served-value`, `-sv` | The value associated with the Predefined Variation. It must respect the setting type | +| `--verbose`, `-v`, `/v` | Print detailed execution information | +| `--non-interactive`, `-ni` | Turn off progress rendering and interactive features | +| `-h`, `/h`, `--help`, `-?`, `/?` | Show help and usage information | +## Parent Command +| Command | Description | +| ------ | ----------- | +| [configcat flag-v2 variation](configcat-flag-v2-variation.md) | Manage Predefined Variations | diff --git a/docs/configcat-flag-v2-variation-ls.md b/docs/configcat-flag-v2-variation-ls.md new file mode 100644 index 0000000..dcc63b8 --- /dev/null +++ b/docs/configcat-flag-v2-variation-ls.md @@ -0,0 +1,21 @@ +# configcat flag-v2 variation ls +List the Predefined Variations of a Feature Flag or Setting +## Usage +``` +configcat flag-v2 variation ls [options] +``` +## Example +``` +configcat flag-v2 variation ls -i +``` +## Options +| Option | Description | +| ------ | ----------- | +| `--flag-id`, `-i`, `--setting-id` | ID of the Feature Flag or Setting | +| `--verbose`, `-v`, `/v` | Print detailed execution information | +| `--non-interactive`, `-ni` | Turn off progress rendering and interactive features | +| `-h`, `/h`, `--help`, `-?`, `/?` | Show help and usage information | +## Parent Command +| Command | Description | +| ------ | ----------- | +| [configcat flag-v2 variation](configcat-flag-v2-variation.md) | Manage Predefined Variations | diff --git a/docs/configcat-flag-v2-variation-rm.md b/docs/configcat-flag-v2-variation-rm.md new file mode 100644 index 0000000..4a7461d --- /dev/null +++ b/docs/configcat-flag-v2-variation-rm.md @@ -0,0 +1,22 @@ +# configcat flag-v2 variation rm +Delete a Predefined Variation +## Usage +``` +configcat flag-v2 variation rm [options] +``` +## Example +``` +configcat flag-v2 variation rm -i -pvi +``` +## Options +| Option | Description | +| ------ | ----------- | +| `--flag-id`, `-i`, `--setting-id` | ID of the Feature Flag or Setting | +| `--predefined-variation-id`, `-pvi` | ID of the Predefined Variation to update | +| `--verbose`, `-v`, `/v` | Print detailed execution information | +| `--non-interactive`, `-ni` | Turn off progress rendering and interactive features | +| `-h`, `/h`, `--help`, `-?`, `/?` | Show help and usage information | +## Parent Command +| Command | Description | +| ------ | ----------- | +| [configcat flag-v2 variation](configcat-flag-v2-variation.md) | Manage Predefined Variations | diff --git a/docs/configcat-flag-v2-variation-update.md b/docs/configcat-flag-v2-variation-update.md new file mode 100644 index 0000000..74ec223 --- /dev/null +++ b/docs/configcat-flag-v2-variation-update.md @@ -0,0 +1,27 @@ +# configcat flag-v2 variation update +Update a Predefined Variation +## Aliases +`up` +## Usage +``` +configcat flag-v2 variation update [options] +``` +## Example +``` +configcat flag-v2 variation up -i -pvi -sv +``` +## Options +| Option | Description | +| ------ | ----------- | +| `--flag-id`, `-i`, `--setting-id` | ID of the Feature Flag or Setting | +| `--predefined-variation-id`, `-pvi` | ID of the Predefined Variation to update | +| `--name`, `-n` | Name of the new Predefined Variation | +| `--hint`, `-H` | Hint of the new Predefined Variation | +| `--served-value`, `-sv` | The value associated with the Predefined Variation. It must respect the setting type | +| `--verbose`, `-v`, `/v` | Print detailed execution information | +| `--non-interactive`, `-ni` | Turn off progress rendering and interactive features | +| `-h`, `/h`, `--help`, `-?`, `/?` | Show help and usage information | +## Parent Command +| Command | Description | +| ------ | ----------- | +| [configcat flag-v2 variation](configcat-flag-v2-variation.md) | Manage Predefined Variations | diff --git a/docs/configcat-flag-v2-variation.md b/docs/configcat-flag-v2-variation.md new file mode 100644 index 0000000..8cd71fc --- /dev/null +++ b/docs/configcat-flag-v2-variation.md @@ -0,0 +1,25 @@ +# configcat flag-v2 variation +Manage Predefined Variations +## Aliases +`var` +## Usage +``` +configcat flag-v2 variation [command] +``` +## Options +| Option | Description | +| ------ | ----------- | +| `--verbose`, `-v`, `/v` | Print detailed execution information | +| `--non-interactive`, `-ni` | Turn off progress rendering and interactive features | +| `-h`, `/h`, `--help`, `-?`, `/?` | Show help and usage information | +## Parent Command +| Command | Description | +| ------ | ----------- | +| [configcat flag-v2](configcat-flag-v2.md) | Manage V2 Feature Flags & Settings | +## Subcommands +| Command | Description | +| ------ | ----------- | +| [configcat flag-v2 variation create](configcat-flag-v2-variation-create.md) | Create a Predefined Variation | +| [configcat flag-v2 variation update](configcat-flag-v2-variation-update.md) | Update a Predefined Variation | +| [configcat flag-v2 variation rm](configcat-flag-v2-variation-rm.md) | Delete a Predefined Variation | +| [configcat flag-v2 variation ls](configcat-flag-v2-variation-ls.md) | List the Predefined Variations of a Feature Flag or Setting | diff --git a/docs/configcat-flag-v2.md b/docs/configcat-flag-v2.md index 8fec5bf..632dd1e 100644 --- a/docs/configcat-flag-v2.md +++ b/docs/configcat-flag-v2.md @@ -27,3 +27,4 @@ configcat flag-v2 [command] | [configcat flag-v2 detach](configcat-flag-v2-detach.md) | Detach Tag(s) from a Feature Flag or Setting | | [configcat flag-v2 value](configcat-flag-v2-value.md) | Manage V2 Feature Flag & Setting default values in different Environments | | [configcat flag-v2 targeting](configcat-flag-v2-targeting.md) | Manage V2 Feature Flag & Setting targeting options | +| [configcat flag-v2 variation](configcat-flag-v2-variation.md) | Manage Predefined Variations | diff --git a/docs/index.md b/docs/index.md index 42cfee6..026f423 100644 --- a/docs/index.md +++ b/docs/index.md @@ -112,6 +112,11 @@ This is the complete list of the available commands provided by the CLI. | [configcat flag-v2 targeting percentage update](configcat-flag-v2-targeting-percentage-update.md) | Update or add the last percentage-only targeting rule | | [configcat flag-v2 targeting percentage clear](configcat-flag-v2-targeting-percentage-clear.md) | Delete the last percentage-only rule | | [configcat flag-v2 targeting percentage attribute](configcat-flag-v2-targeting-percentage-attribute.md) | Set the percentage evaluation attribute | +| [configcat flag-v2 variation](configcat-flag-v2-variation.md) | Manage Predefined Variations | +| [configcat flag-v2 variation create](configcat-flag-v2-variation-create.md) | Create a Predefined Variation | +| [configcat flag-v2 variation update](configcat-flag-v2-variation-update.md) | Update a Predefined Variation | +| [configcat flag-v2 variation rm](configcat-flag-v2-variation-rm.md) | Delete a Predefined Variation | +| [configcat flag-v2 variation ls](configcat-flag-v2-variation-ls.md) | List the Predefined Variations of a Feature Flag or Setting | ### configcat segment | Command | Description | | ------ | ----------- | diff --git a/src/ConfigCat.Cli.Models/Api/CreateFlagModel.cs b/src/ConfigCat.Cli.Models/Api/CreateFlagModel.cs index fa08046..1d08840 100644 --- a/src/ConfigCat.Cli.Models/Api/CreateFlagModel.cs +++ b/src/ConfigCat.Cli.Models/Api/CreateFlagModel.cs @@ -15,9 +15,11 @@ public class CreateFlagModel public string Type { get; set; } [JsonPropertyName("tags")] - public IEnumerable TagIds { get; set; } + public List TagIds { get; set; } - public IEnumerable InitialValues { get; set; } + public List InitialValues { get; set; } + + public List PredefinedVariations { get; set; } } public class InitialValue diff --git a/src/ConfigCat.Cli.Models/Api/FlagModel.cs b/src/ConfigCat.Cli.Models/Api/FlagModel.cs index cc63da1..d756775 100644 --- a/src/ConfigCat.Cli.Models/Api/FlagModel.cs +++ b/src/ConfigCat.Cli.Models/Api/FlagModel.cs @@ -29,6 +29,8 @@ public class FlagModel public List Tags { get; set; } + public List PredefinedVariations { get; set; } + [JsonIgnore] public List Aliases { get; set; } = []; @@ -41,4 +43,20 @@ public UpdateFlagModel ToUpdateModel() => }; } -public class DeletedFlagModel : FlagModel { } \ No newline at end of file +public class DeletedFlagModel : FlagModel { } + +public class VariationsModel +{ + public List PredefinedVariations { get; set; } +} + +public class VariationModel +{ + public ValueModel Value { get; set; } + + public string Name { get; set; } + + public string Hint { get; set; } + + public string PredefinedVariationId { get; set; } +} \ No newline at end of file diff --git a/src/ConfigCat.Cli.Models/Api/FlagValueV2Model.cs b/src/ConfigCat.Cli.Models/Api/FlagValueV2Model.cs index ffe902b..cb65ff8 100644 --- a/src/ConfigCat.Cli.Models/Api/FlagValueV2Model.cs +++ b/src/ConfigCat.Cli.Models/Api/FlagValueV2Model.cs @@ -1,10 +1,11 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace ConfigCat.Cli.Models.Api; public class FlagValueV2Model { - public ValueModel DefaultValue { get; set; } + public ValueWithPredefinedVariationModel DefaultValue { get; set; } public List TargetingRules { get; set; } public string PercentageEvaluationAttribute { get; set; } public FlagModel Setting { get; set; } @@ -12,17 +13,30 @@ public class FlagValueV2Model public class ValueModel { - public bool? BoolValue { get; set; } - public string StringValue { get; set; } - public int? IntValue { get; set; } - public double? DoubleValue { get; set; } + public bool? BoolValue { get; init; } + public string StringValue { get; init; } + public int? IntValue { get; init; } + public double? DoubleValue { get; init; } + + + public override string ToString() => this.StringValue ?? this.BoolValue?.ToString() ?? this.IntValue?.ToString() ?? this.DoubleValue?.ToString() ?? string.Empty; + + public override bool Equals(object obj) => + obj is ValueModel other && BoolValue == other.BoolValue && StringValue == other.StringValue && IntValue == other.IntValue && Nullable.Equals(DoubleValue, other.DoubleValue); + + public override int GetHashCode() => HashCode.Combine(BoolValue, StringValue, IntValue, DoubleValue); +} + +public class ValueWithPredefinedVariationModel : ValueModel +{ + public string PredefinedVariationId { get; set; } } public class TargetingRuleModel { public List Conditions { get; set; } public List PercentageOptions { get; set; } - public ValueModel Value { get; set; } + public ValueWithPredefinedVariationModel Value { get; set; } } public class ConditionModel @@ -49,7 +63,7 @@ public class PrerequisiteFlagConditionModel { public int PrerequisiteSettingId { get; set; } public string Comparator { get; set; } - public ValueModel PrerequisiteComparisonValue { get; set; } + public ValueWithPredefinedVariationModel PrerequisiteComparisonValue { get; set; } } public class ComparisonValueModel @@ -68,5 +82,5 @@ public class ComparisonValueListModel public class PercentageOptionModel { public int Percentage { get; set; } - public ValueModel Value { get; set; } + public ValueWithPredefinedVariationModel Value { get; set; } } \ No newline at end of file diff --git a/src/ConfigCat.Cli.Services/Api/VariationClient.cs b/src/ConfigCat.Cli.Services/Api/VariationClient.cs new file mode 100644 index 0000000..dd013c9 --- /dev/null +++ b/src/ConfigCat.Cli.Services/Api/VariationClient.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ConfigCat.Cli.Models.Api; +using ConfigCat.Cli.Models.Configuration; +using ConfigCat.Cli.Services.Rendering; +using Trybot; + +namespace ConfigCat.Cli.Services.Api; + +public interface IVariationClient +{ + Task UpdateVariationsAsync(int flagId, List updatedModel, CancellationToken token); +} + +public class VariationClient( + IOutput output, + CliConfig config, + IBotPolicy botPolicy, + HttpClient httpClient) + : ApiClient(output, config, botPolicy, httpClient), IVariationClient +{ + public Task UpdateVariationsAsync(int flagId, List updatedModel, + CancellationToken token) => + this.SendAsync(HttpMethod.Put, $"v1/settings/{flagId}/predefined-variations", + new VariationsModel { PredefinedVariations = updatedModel }, token); +} \ No newline at end of file diff --git a/src/ConfigCat.Cli.Services/ConfigFile/ConfigJsonConverter.cs b/src/ConfigCat.Cli.Services/ConfigFile/ConfigJsonConverter.cs index 9764d65..73a5957 100644 --- a/src/ConfigCat.Cli.Services/ConfigFile/ConfigJsonConverter.cs +++ b/src/ConfigCat.Cli.Services/ConfigFile/ConfigJsonConverter.cs @@ -116,7 +116,7 @@ TargetingRuleV6 ConvertTargetingRule(RolloutRuleV5 ruleV5, string settingKey, Se { Conditions = [ - new() { UserCondition = ConvertComparisonRule(ruleV5, settingKey) } + new ConditionV6 { UserCondition = ConvertComparisonRule(ruleV5, settingKey) } ], ServedValue = servedValue }; diff --git a/src/ConfigCat.Cli.Services/Extensions/ModelExtensions.cs b/src/ConfigCat.Cli.Services/Extensions/ModelExtensions.cs index 56a10bb..5977fb4 100644 --- a/src/ConfigCat.Cli.Services/Extensions/ModelExtensions.cs +++ b/src/ConfigCat.Cli.Services/Extensions/ModelExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using ConfigCat.Cli.Models.Api; using ConfigCat.Cli.Models.Configuration; @@ -7,9 +8,18 @@ namespace ConfigCat.Cli.Services.Extensions; public static class ModelExtensions { - public static object ToSingle(this ValueModel model, string settingType) + public static object ToSingle(this ValueWithPredefinedVariationModel model, FlagModel flagModel) { - return settingType switch + if (!model.PredefinedVariationId.IsEmpty()) + { + var variation = flagModel.PredefinedVariations.FirstOrDefault(p => p.PredefinedVariationId == model.PredefinedVariationId); + if (variation is not null) + { + return variation.Name ?? variation.Value.ToString(); + } + } + + return flagModel.SettingType switch { SettingTypes.Boolean => model.BoolValue, SettingTypes.String => model.StringValue, diff --git a/src/ConfigCat.Cli.Services/Extensions/SystemExtensions.cs b/src/ConfigCat.Cli.Services/Extensions/SystemExtensions.cs index 34294b9..495b840 100644 --- a/src/ConfigCat.Cli.Services/Extensions/SystemExtensions.cs +++ b/src/ConfigCat.Cli.Services/Extensions/SystemExtensions.cs @@ -27,29 +27,25 @@ public static string NullIfEmpty(this string value) => public static bool IsEmptyOrEquals(this string value, string other) => string.IsNullOrWhiteSpace(value) || value.Equals(other); - public static bool TryParseFlagValue(this string value, string settingType, out object parsed) + public static ValueWithPredefinedVariationModel ToFlagValueWithVariation(this string value, string settingType, + List variations) { - parsed = null; - switch (settingType) + if (!variations.IsEmpty()) { - case SettingTypes.Boolean: - if (!bool.TryParse(value, out var boolParsed)) return false; - parsed = boolParsed; - return true; - case SettingTypes.Int: - if (!int.TryParse(value, out var intParsed)) return false; - parsed = intParsed; - return true; - case SettingTypes.Double: - if (!double.TryParse(value, out var doubleParsed)) return false; - parsed = doubleParsed; - return true; - case SettingTypes.String: - parsed = value; - return true; - default: - return false; + var variation = variations.FirstOrDefault(v => v.PredefinedVariationId == value || v.Name == value); + return variation is null + ? throw new ShowHelpException($"Predefined variation '{value}' not found") + : new ValueWithPredefinedVariationModel { PredefinedVariationId = variation.PredefinedVariationId }; } + + var valueModel = value.ToFlagValue(settingType); + return new ValueWithPredefinedVariationModel + { + BoolValue = valueModel.BoolValue, + DoubleValue = valueModel.DoubleValue, + IntValue = valueModel.IntValue, + StringValue = valueModel.StringValue + }; } public static ValueModel ToFlagValue(this string value, string settingType) @@ -65,29 +61,47 @@ public static ValueModel ToFlagValue(this string value, string settingType) return new ValueModel { IntValue = intParsed }; break; case SettingTypes.Double: - if (double.TryParse(value, out var doubleParsed)) + if (double.TryParse(value, out var doubleParsed)) return new ValueModel { DoubleValue = doubleParsed }; break; case SettingTypes.String: return new ValueModel { StringValue = value }; } - throw new ShowHelpException($"Value '{value}' doesn't conform the setting type '{settingType}'"); + + throw new ShowHelpException($"Value '{value}' doesn't conform to the setting type '{settingType}'"); } - public static string ToValuePropertyName(this string settingType) + public static object ToObjectValue(this string value, string settingType) { - return settingType switch + switch (settingType) { - SettingTypes.Boolean => "boolValue", - SettingTypes.String => "stringValue", - SettingTypes.Int => "intValue", - SettingTypes.Double => "doubleValue", - _ => "" - }; + case SettingTypes.Boolean: + if (bool.TryParse(value, out var boolParsed)) + return boolParsed; + break; + case SettingTypes.Int: + if (int.TryParse(value, out var intParsed)) + return intParsed; + break; + case SettingTypes.Double: + if (double.TryParse(value, out var doubleParsed)) + return doubleParsed; + break; + case SettingTypes.String: + return value; + } + + throw new ShowHelpException($"Value '{value}' doesn't conform to the setting type '{settingType}'"); } - public static string GetDefaultValueForType(this string type) => - type switch + public static string GetDefaultValueForType(this string type, List variations) + { + if (!variations.IsEmpty()) + { + return variations.First().Value.ToString(); + } + + return type switch { SettingTypes.Boolean => "false", SettingTypes.Int => "42", @@ -95,6 +109,7 @@ public static string GetDefaultValueForType(this string type) => SettingTypes.String => "initial value", _ => "" }; + } public static string Cut(this string text, int length) => text == null ? string.Empty : text.Length > length ? $"{text[..(length - 3)]}..." : text; @@ -124,7 +139,7 @@ public static bool IsListComparator(this string comparator) => "arrayDoesNotContainAny" => true, _ => false }; - + public static bool IsNumberComparator(this string comparator) => comparator switch { @@ -136,13 +151,13 @@ public static bool IsNumberComparator(this string comparator) => "numberGreaterOrEquals" => true, _ => false }; - + public static string TrimToFitColumn(this string text) => text == null ? "\"\"" : $"\"{text.TrimToLength(30)}\""; - + public static string TrimToLength(this string text, int length) => text.Length > length ? $"{text[..(length - 2)]}..." : text; - - public static object FormatIfBool(this object val) - => val is bool b ? b.ToString().ToLowerInvariant() : val; + + public static object FormatIfBool(this object val) + => val is bool b ? b.ToString().ToLowerInvariant() : val; } \ No newline at end of file diff --git a/src/ConfigCat.Cli.Services/Rendering/Prompt.cs b/src/ConfigCat.Cli.Services/Rendering/Prompt.cs index 340696b..a28982d 100644 --- a/src/ConfigCat.Cli.Services/Rendering/Prompt.cs +++ b/src/ConfigCat.Cli.Services/Rendering/Prompt.cs @@ -16,10 +16,10 @@ Task GetStringAsync(string label, Task GetMaskedStringAsync(string label, CancellationToken token, string defaultValue = null); - + Task>> GetRepeatedValuesAsync(string label, CancellationToken token, - List values); + List values); Task ChooseFromListAsync(string label, List items, @@ -34,6 +34,12 @@ Task> ChooseMultipleFromListAsync(string label, List preSelectedItems = null); } +public class RepeatedValuesDescriptor +{ + public string Label { get; set; } + public Func> Reader { get; set; } = (p, l, t) => p.GetStringAsync(l, t); +} + public class Prompt(IOutput output, CliOptions options) : IPrompt { private const int DefaultPageSize = 15; @@ -74,7 +80,8 @@ public async Task GetMaskedStringAsync(string label, return result.IsEmpty() ? defaultValue : result; } - public async Task>> GetRepeatedValuesAsync(string label, CancellationToken token, List values) + public async Task>> GetRepeatedValuesAsync(string label, CancellationToken token, + List values) { if (token.IsCancellationRequested || output.IsOutputRedirected || @@ -94,17 +101,16 @@ public async Task>> GetRepeatedValuesAsync(string label, Cance var valueResult = new List(); foreach (var value in values) { - output.Write(value).Write(": "); - var read = await output.ReadLineAsync(token); - if (read.IsEmpty()) return null; - valueResult.Add(read); - output.WriteLine(); + var promptValue = await value.Reader(this, value.Label, token); + valueResult.Add(promptValue); } + result.Add(valueResult); } + return result; } - + public async Task ChooseFromListAsync(string label, List items, Func labelSelector, @@ -120,16 +126,19 @@ public async Task ChooseFromListAsync(string label, output.Write(label).Write(":").WriteLine(); - output.WriteDarkGray("(Use the ").WriteCyan("UP").WriteDarkGray(" and ").WriteCyan("DOWN").WriteDarkGray(" keys to navigate)") + output.WriteDarkGray("(Use the ").WriteCyan("UP").WriteDarkGray(" and ").WriteCyan("DOWN") + .WriteDarkGray(" keys to navigate)") .WriteLine().WriteLine(); var pages = this.GetPages(items); - var pageIndex = selectedValue is null || selectedValue.Equals(default(TItem)) ? 0 : pages.PageIndexOf(selectedValue); + var pageIndex = selectedValue is null || selectedValue.Equals(default(TItem)) + ? 0 + : pages.PageIndexOf(selectedValue); var page = pages[pageIndex]; - int index = this.PrintChooseSection(page, selectedValue, labelSelector, pageIndex, pages.Count); - ConsoleKeyInfo key; + var index = this.PrintChooseSection(page, selectedValue, labelSelector, pageIndex, pages.Count); try { + ConsoleKeyInfo key; do { key = await output.ReadKeyAsync(token, true); @@ -146,7 +155,8 @@ public async Task ChooseFromListAsync(string label, break; case ConsoleKey.DownArrow: - if (index >= page.Count - 1 || page[index + 1] is null || page[index + 1].Equals(default(TItem))) + if (index >= page.Count - 1 || page[index + 1] is null || + page[index + 1].Equals(default(TItem))) continue; output.ClearCurrentLine(); @@ -198,7 +208,7 @@ public async Task> ChooseMultipleFromListAsync(string label, if (token.IsCancellationRequested || output.IsOutputRedirected || options.IsNonInteractive) - return default; + return null; using var _ = output.CreateCursorHider(); @@ -219,9 +229,9 @@ public async Task> ChooseMultipleFromListAsync(string label, var pages = this.GetPages(items); var page = pages[pageIndex]; this.PrintMultiChooseSection(page, labelSelector, selectedItems, pageIndex, pages.Count); - ConsoleKeyInfo key; try { + ConsoleKeyInfo key; do { key = await output.ReadKeyAsync(token, true); @@ -239,7 +249,8 @@ public async Task> ChooseMultipleFromListAsync(string label, break; case ConsoleKey.DownArrow: - if (index >= page.Count - 1 || page[index + 1] is null || page[index + 1].Equals(default(TItem))) + if (index >= page.Count - 1 || page[index + 1] is null || + page[index + 1].Equals(default(TItem))) continue; output.ClearCurrentLine(); @@ -282,6 +293,7 @@ public async Task> ChooseMultipleFromListAsync(string label, output.ClearCurrentLine(); this.PrintSelectedInMulti(item, labelSelector, selectedItems); } + break; } } while (!token.IsCancellationRequested && @@ -289,13 +301,15 @@ public async Task> ChooseMultipleFromListAsync(string label, output.ClearCurrentLine(); this.PrintNonSelectedInMulti(page[index], labelSelector, selectedItems); - output.SetCursorPosition(0, output.CursorTop + page.Count - index + (pages.Count > 1 ? 1 : 0)).ClearCurrentLine().WriteLine(); + output.SetCursorPosition(0, output.CursorTop + page.Count - index + (pages.Count > 1 ? 1 : 0)) + .ClearCurrentLine().WriteLine(); return selectedItems; } catch (OperationCanceledException) { - output.SetCursorPosition(0, output.CursorTop + page.Count - index + (pages.Count > 1 ? 1 : 0)).ClearCurrentLine(); + output.SetCursorPosition(0, output.CursorTop + page.Count - index + (pages.Count > 1 ? 1 : 0)) + .ClearCurrentLine(); throw; } } @@ -389,11 +403,13 @@ private void PrintSelected(TItem item, { if (isHighlight) { - output.WriteColor($"| {(showIndicator ? ">" : " ")} {labelSelector(item)}", ConsoleColor.White, ConsoleColor.DarkMagenta); + output.WriteColor($"| {(showIndicator ? ">" : " ")} {labelSelector(item)}", ConsoleColor.White, + ConsoleColor.DarkMagenta); return; } - output.WriteColor("|", ConsoleColor.DarkGray).WriteColor($" > ", ConsoleColor.Magenta).Write(labelSelector(item)); + output.WriteColor("|", ConsoleColor.DarkGray).WriteColor($" > ", ConsoleColor.Magenta) + .Write(labelSelector(item)); } private void PrintNonSelected(TItem item, Func labelSelector) @@ -415,7 +431,8 @@ private void PrintSelectedInMulti(TItem item, Func labelSe this.PrintSelected(item, labelSelector, false); } - private void PrintNonSelectedInMulti(TItem item, Func labelSelector, List selectedItems) + private void PrintNonSelectedInMulti(TItem item, Func labelSelector, + List selectedItems) { if (selectedItems.Contains(item)) this.PrintSelected(item, labelSelector, true); diff --git a/src/ConfigCat.Cli/CommandBuilder.cs b/src/ConfigCat.Cli/CommandBuilder.cs index 3c16d8e..7bbc6e3 100644 --- a/src/ConfigCat.Cli/CommandBuilder.cs +++ b/src/ConfigCat.Cli/CommandBuilder.cs @@ -859,10 +859,74 @@ private static CommandDescriptor BuildFlagV2Command() => SubCommands = ManageFlagCommands.Concat( [ BuildFlagValueV2Command(), - BuildV2FlagTargetingCommand() + BuildV2FlagTargetingCommand(), + BuildVariationCommand(), ]) }; + private static CommandDescriptor BuildVariationCommand() => + new("variation", "Manage Predefined Variations") + { + Aliases = ["var"], + SubCommands = + [ + new CommandDescriptor("create", "Create a Predefined Variation", "configcat flag-v2 variation create -i -name -sv ") + { + Handler = CreateHandler(nameof(Variation.CreateAsync)), + Aliases = ["cr"], + Options = + [ + new Option(["--flag-id", "-i", "--setting-id"], "ID of the Feature Flag or Setting") + { + Name = "--flag-id" + }, + new Option(["--name", "-n"], "Name of the new Predefined Variation"), + new Option(["--hint", "-H"], "Hint of the new Predefined Variation"), + new Option(["--served-value", "-sv"], "The value associated with the Predefined Variation. It must respect the setting type"), + ] + }, + new CommandDescriptor("update", "Update a Predefined Variation", "configcat flag-v2 variation up -i -pvi -sv ") + { + Handler = CreateHandler(nameof(Variation.UpdateAsync)), + Aliases = ["up"], + Options = + [ + new Option(["--flag-id", "-i", "--setting-id"], "ID of the Feature Flag or Setting") + { + Name = "--flag-id" + }, + new Option(["--predefined-variation-id", "-pvi"], "ID of the Predefined Variation to update"), + new Option(["--name", "-n"], "Name of the new Predefined Variation"), + new Option(["--hint", "-H"], "Hint of the new Predefined Variation"), + new Option(["--served-value", "-sv"], "The value associated with the Predefined Variation. It must respect the setting type"), + ] + }, + new CommandDescriptor("rm", "Delete a Predefined Variation", "configcat flag-v2 variation rm -i -pvi ") + { + Handler = CreateHandler(nameof(Variation.DeleteAsync)), + Options = + [ + new Option(["--flag-id", "-i", "--setting-id"], "ID of the Feature Flag or Setting") + { + Name = "--flag-id" + }, + new Option(["--predefined-variation-id", "-pvi"], "ID of the Predefined Variation to update"), + ] + }, + new CommandDescriptor("ls", "List the Predefined Variations of a Feature Flag or Setting", "configcat flag-v2 variation ls -i ") + { + Handler = CreateHandler(nameof(Variation.ListAsync)), + Options = + [ + new Option(["--flag-id", "-i", "--setting-id"], "ID of the Feature Flag or Setting") + { + Name = "--flag-id" + } + ] + }, + ] + }; + private static CommandDescriptor BuildFlagValueV2Command() => new("value", "Manage V2 Feature Flag & Setting default values in different Environments") { @@ -932,7 +996,7 @@ private static CommandDescriptor BuildV2FlagTargetingCommand() => new Option(["--comparator", "-c"], "The operator which defines the relation between the comparison attribute and the comparison value") .AddSuggestions(Constants.UserComparatorTypes.Keys.ToArray()), new Option(["--comparison-value", "-cv"], "The value that the User Object attribute is compared to. Can be a double, string, or value-hint list in the format: `:`"), - new Option(["--served-value", "-sv"], "The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type"), + new Option(["--served-value", "-sv"], "The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type. When the flag has Predefined Variations, it must be either the Variation's ID or name"), new PercentageOptionArgument(), ReasonOption ] @@ -952,7 +1016,7 @@ private static CommandDescriptor BuildV2FlagTargetingCommand() => new Option(["--comparator", "-c"], "The operator which defines the expected result of the evaluation of the segment") .AddSuggestions(Constants.SegmentComparatorTypes.Keys.ToArray()), new Option(["--segment-id", "-si"], "ID of the segment that the condition is based on"), - new Option(["--served-value", "-sv"], "The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type"), + new Option(["--served-value", "-sv"], "The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type. When the flag has Predefined Variations, it must be either the Variation's ID or name"), new PercentageOptionArgument(), ReasonOption ] @@ -972,8 +1036,8 @@ private static CommandDescriptor BuildV2FlagTargetingCommand() => new Option(["--comparator", "-c"], "The operator which defines the relation between the evaluated value of the prerequisite flag and the comparison value") .AddSuggestions(Constants.PrerequisiteComparatorTypes.Keys.ToArray()), new Option(["--prerequisite-id", "-pi"], "ID of the prerequisite flag that the condition is based on"), - new Option(["--prerequisite-value", "-pv"], "The evaluated value of the prerequisite flag is compared to. It must respect the prerequisite flag's setting type"), - new Option(["--served-value", "-sv"], "The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type"), + new Option(["--prerequisite-value", "-pv"], "The evaluated value of the prerequisite flag is compared to. It must respect the prerequisite flag's setting type. When the prerequisite flag has Predefined Variations, it either must be the Variation's ID or name"), + new Option(["--served-value", "-sv"], "The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type. When the flag has Predefined Variations, it must be either the Variation's ID or name"), new PercentageOptionArgument(), ReasonOption ] @@ -1025,7 +1089,7 @@ private static CommandDescriptor BuildV2FlagTargetingCommand() => }, new Option(["--environment-id", "-e"], "ID of the Environment where the rule should be moved"), new Option(["--rule-position", "-rp"], "The position of the targeting rule"), - new Option(["--served-value", "-sv"], "The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type"), + new Option(["--served-value", "-sv"], "The value associated with the targeting rule. Leave it empty if the targeting rule has percentage options. It must respect the setting type. When the flag has Predefined Variations, it must be either the Variation's ID or name"), new PercentageOptionArgument(), ReasonOption ] @@ -1097,7 +1161,7 @@ private static CommandDescriptor BuildV2FlagTargetingCommand() => new Option(["--comparator", "-c"], "The operator which defines the relation between the evaluated value of the prerequisite flag and the comparison value") .AddSuggestions(Constants.PrerequisiteComparatorTypes.Keys.ToArray()), new Option(["--prerequisite-id", "-pi"], "ID of the prerequisite flag that the condition is based on"), - new Option(["--prerequisite-value", "-pv"], "The evaluated value of the prerequisite flag is compared to. It must respect the prerequisite flag's setting type"), + new Option(["--prerequisite-value", "-pv"], "The evaluated value of the prerequisite flag is compared to. It must respect the prerequisite flag's setting type. When the prerequisite flag has Predefined Variations, it must be either the Variation's ID or name"), ReasonOption ] }, @@ -1204,6 +1268,7 @@ private static CommandDescriptor BuildV2FlagTargetingCommand() => new Option(["--hint", "-H"], "Hint of the new Flag or Setting"), new Option(["--init-value", "-iv"], "Initial value for each Environment"), new FlagInitialValuesOption(), + new FlagPredefinedVariationOption(), new Option(["--type", "-t"], "Type of the new Flag or Setting") .AddSuggestions(SettingTypes.Collection), new Option(["--tag-ids", "-g"], "Tags to attach") @@ -1317,7 +1382,7 @@ private static CommandDescriptor BuildConfigJsonCommand() => { SubCommands = [ - new("convert", "Convert between config JSON versions", "configcat config-json convert v5-to-v6 < config_v5.json") + new CommandDescriptor("convert", "Convert between config JSON versions", "configcat config-json convert v5-to-v6 < config_v5.json") { Handler = CreateHandler(nameof(ConfigJsonConvert.ExecuteAsync)), Arguments = @@ -1334,7 +1399,7 @@ private static CommandDescriptor BuildConfigJsonCommand() => new Option(["--pretty", "-p"], "Pretty print the converted JSON.") ], }, - new("get", "Download a config JSON from the CDN servers.", "configcat config-json get -f v6 PKDVCLf-Hq-h-kCzMp-L7Q/HhOWfwVtZ0mb30i9wi17GQ > config.json") + new CommandDescriptor("get", "Download a config JSON from the CDN servers.", "configcat config-json get -f v6 PKDVCLf-Hq-h-kCzMp-L7Q/HhOWfwVtZ0mb30i9wi17GQ > config.json") { Handler = CreateHandler(nameof(ConfigJsonGet.ExecuteAsync)), Arguments = diff --git a/src/ConfigCat.Cli/Commands/Eval.cs b/src/ConfigCat.Cli/Commands/Eval.cs index 60cdc16..c7ea1f9 100644 --- a/src/ConfigCat.Cli/Commands/Eval.cs +++ b/src/ConfigCat.Cli/Commands/Eval.cs @@ -43,7 +43,7 @@ public async Task InvokeAsync(string sdkKey, if (flagKeys.IsEmpty()) flagKeys = (await prompt.GetRepeatedValuesAsync("Set the feature flag keys that you want to evaluate", - token, ["Flag key"])).SelectMany(t => t).ToArray(); + token, [new RepeatedValuesDescriptor { Label = "Flag key" }])).SelectMany(t => t).ToArray(); var client = CreateConfigCatClient(sdkKey, baseUrl, dataGovernance); await client.ForceRefreshAsync(token); diff --git a/src/ConfigCat.Cli/Commands/Flags/Flag.cs b/src/ConfigCat.Cli/Commands/Flags/Flag.cs index efdc3f5..f7487ad 100644 --- a/src/ConfigCat.Cli/Commands/Flags/Flag.cs +++ b/src/ConfigCat.Cli/Commands/Flags/Flag.cs @@ -22,7 +22,8 @@ internal class Flag( IPrompt prompt, IOutput output) { - public async Task ListAllFlagsAsync(string configId, string tagName, int? tagId, bool json, CancellationToken token) + public async Task ListAllFlagsAsync(string configId, string tagName, int? tagId, bool json, + CancellationToken token) { var flags = new List(); if (!configId.IsEmpty()) @@ -75,7 +76,7 @@ public async Task ListAllFlagsAsync(string configId, string tagName, int? t return ExitCodes.Ok; } - public async Task CreateFlagAsync(string configId, + public async Task CreateFlagAsync(string configId, string key, string name, string hint, @@ -83,6 +84,7 @@ public async Task CreateFlagAsync(string configId, string initValue, int[] tagIds, InitialValueOption[] initValuesPerEnvironment, + PredefinedVariationOption[] predefinedVariations, CancellationToken token) { var shouldPrompt = configId.IsEmpty(); @@ -103,45 +105,78 @@ public async Task CreateFlagAsync(string configId, type = await prompt.ChooseFromListAsync("Choose type", SettingTypes.Collection.ToList(), t => t, token); if (shouldPrompt && tagIds.IsEmpty()) - tagIds = (await workspaceLoader.LoadTagsAsync(token, configId, optional: true)).Select(t => t.TagId).ToArray(); + tagIds = (await workspaceLoader.LoadTagsAsync(token, configId, optional: true)).Select(t => t.TagId) + .ToArray(); if (!SettingTypes.Collection.ToList() .Contains(type, StringComparer.OrdinalIgnoreCase)) - throw new ShowHelpException($"Type must be one of the following: {string.Join('|', SettingTypes.Collection)}"); + throw new ShowHelpException( + $"Type must be one of the following: {string.Join('|', SettingTypes.Collection)}"); + + if (shouldPrompt && predefinedVariations.IsEmpty()) + { + var valueType = await prompt.ChooseFromListAsync("What type of values this flag should use?", + ["Free-form values", "Predefined variations"], t => t, token); + if (valueType == "Predefined variations") + { + var promptResult = await prompt.GetRepeatedValuesAsync("Add predefined variations", token, [ + new RepeatedValuesDescriptor { Label = "Name" }, new RepeatedValuesDescriptor { Label = "Value" } + ]); + predefinedVariations = promptResult.Select(p => new PredefinedVariationOption + { Name = p[0], Value = p[1] }).ToArray(); + } + } - object parsedInitialValue = null; - if (!initValue.IsEmpty() && !initValue.TryParseFlagValue(type, out parsedInitialValue)) - throw new ShowHelpException($"Initial value '{initValue}' must respect the type '{type}'."); - var createModel = new CreateFlagModel { Hint = hint, Key = key, Name = name, - TagIds = tagIds, - Type = type + TagIds = tagIds.ToList(), + Type = type, + PredefinedVariations = predefinedVariations.IsEmpty() + ? null + : predefinedVariations.Select(p => new VariationModel + { Name = p.Name, Value = p.Value.ToFlagValue(type) }).ToList(), }; + var parsedInitialValue = initValue.IsEmpty() ? null : initValue.ToObjectValue(type); + ConfigModel config = null; List environments = null; if (shouldPrompt && initValue.IsEmpty() && initValuesPerEnvironment.IsEmpty()) { config = await configClient.GetConfigAsync(configId, token); environments = (await environmentClient.GetEnvironmentsAsync(config.Product.ProductId, token)).ToList(); - var defaultValue = type.GetDefaultValueForType(); initValuesPerEnvironment = new InitialValueOption[environments.Count]; output.WriteDarkGray("Please set an initial value for each of your environments.") .WriteLine(); - var index = 0; - foreach (var environment in environments) + if (!createModel.PredefinedVariations.IsEmpty()) + { + var index = 0; + foreach (var environment in environments) + { + var fromPrompt = (await prompt.ChooseFromListAsync($"Choose variation for {environment.Name}", + createModel.PredefinedVariations, + v => v.Name ?? v.Value.ToString(), token)).Value.ToString(); + initValuesPerEnvironment[index++] = new InitialValueOption + { EnvironmentId = environment.EnvironmentId, Value = fromPrompt }; + } + } + else { - var fromPrompt = await prompt.GetStringAsync(environment.Name, token, defaultValue); - initValuesPerEnvironment[index++] = new InitialValueOption - { EnvironmentId = environment.EnvironmentId, Value = fromPrompt }; + var defaultValue = type.GetDefaultValueForType(createModel.PredefinedVariations); + var index = 0; + foreach (var environment in environments) + { + var fromPrompt = await prompt.GetStringAsync(environment.Name, token, defaultValue); + initValuesPerEnvironment[index++] = new InitialValueOption + { EnvironmentId = environment.EnvironmentId, Value = fromPrompt }; + } } } - + if (parsedInitialValue is not null || !initValuesPerEnvironment.IsEmpty()) { config ??= await configClient.GetConfigAsync(configId, token); @@ -150,20 +185,14 @@ public async Task CreateFlagAsync(string configId, { var initial = new InitialValue { EnvironmentId = env.EnvironmentId }; var perEnv = initValuesPerEnvironment?.FirstOrDefault(i => i.EnvironmentId == env.EnvironmentId); - if (perEnv is not null) - { - if (!perEnv.Value.TryParseFlagValue(type, out var parsed)) - throw new ShowHelpException($"Initial value '{perEnv.Value}' must respect the type '{type}'."); - - initial.Value = parsed; - } - else - initial.Value = parsedInitialValue; + initial.Value = perEnv is not null + ? perEnv.Value.ToObjectValue(type) + : parsedInitialValue; return initial.Value is null ? null : initial; }).Where(i => i is not null).ToList(); } - + var result = await flagClient.CreateFlagAsync(configId, createModel, token); output.Write(result.SettingId.ToString()); @@ -193,7 +222,9 @@ public async Task UpdateFlagAsync(int? flagId, UpdateFlagModel updateFlagMo updateFlagModel.Hint = await prompt.GetStringAsync("Hint", token, flag.Hint); if (updateFlagModel.TagIds.IsEmpty()) - updateFlagModel.TagIds = (await workspaceLoader.LoadTagsAsync(token, flag.ConfigId, flag.Tags, optional: true)).Select(t => t.TagId).ToArray(); + updateFlagModel.TagIds = + (await workspaceLoader.LoadTagsAsync(token, flag.ConfigId, flag.Tags, optional: true)) + .Select(t => t.TagId).ToArray(); } var originalTagIds = flag.Tags?.Select(t => t.TagId).ToList() ?? []; @@ -244,7 +275,8 @@ public async Task AttachTagsAsync(int? flagId, int[] tagIds, CancellationTo var flagTagIds = flag.Tags.Select(t => t.TagId).ToList(); if (flagId is null && tagIds.IsEmpty()) - tagIds = (await workspaceLoader.LoadTagsAsync(token, flag.ConfigId, flag.Tags)).Select(t => t.TagId).ToArray(); + tagIds = (await workspaceLoader.LoadTagsAsync(token, flag.ConfigId, flag.Tags)).Select(t => t.TagId) + .ToArray(); if (tagIds.IsEmpty() || tagIds.SequenceEqual(flagTagIds) || @@ -269,7 +301,8 @@ public async Task DetachTagsAsync(int? flagId, int[] tagIds, CancellationTo : await flagClient.GetFlagAsync(flagId.Value, token); if (flagId is null && tagIds.IsEmpty()) - tagIds = (await prompt.ChooseMultipleFromListAsync("Choose tags to detach", flag.Tags, t => t.Name, token)).Select(t => t.TagId).ToArray(); + tagIds = (await prompt.ChooseMultipleFromListAsync("Choose tags to detach", flag.Tags, t => t.Name, token)) + .Select(t => t.TagId).ToArray(); var relevantTags = flag.Tags.Where(t => tagIds.Contains(t.TagId)).ToList(); if (relevantTags.Count == 0) diff --git a/src/ConfigCat.Cli/Commands/Flags/FlagPercentage.cs b/src/ConfigCat.Cli/Commands/Flags/FlagPercentage.cs index a3bfaab..6861ba5 100644 --- a/src/ConfigCat.Cli/Commands/Flags/FlagPercentage.cs +++ b/src/ConfigCat.Cli/Commands/Flags/FlagPercentage.cs @@ -2,7 +2,7 @@ using ConfigCat.Cli.Services.Api; using ConfigCat.Cli.Options; using System; -using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using ConfigCat.Cli.Services.Exceptions; @@ -38,14 +38,9 @@ public async Task UpdatePercentageRulesAsync(int? flagId, string environmen if (value.Setting.SettingType == SettingTypes.Boolean && rules.Length != 2) throw new ShowHelpException($"Boolean type can only have 2 percentage rules."); - var result = new List(); - foreach (var percentageRule in rules) - { - if (!percentageRule.Value.TryParseFlagValue(value.Setting.SettingType, out var parsed)) - throw new ShowHelpException($"Flag value '{percentageRule.Value}' must respect the type '{value.Setting.SettingType}'."); - - result.Add(new PercentageModel { Percentage = percentageRule.Percentage, Value = parsed }); - } + var result = rules + .Select(percentageRule => new PercentageModel { Percentage = percentageRule.Percentage, Value = percentageRule.Value.ToObjectValue(value.Setting.SettingType) }) + .ToList(); if (value.Setting.SettingType == SettingTypes.Boolean && ((bool)result[0].Value && (bool)result[1].Value || diff --git a/src/ConfigCat.Cli/Commands/Flags/FlagTargeting.cs b/src/ConfigCat.Cli/Commands/Flags/FlagTargeting.cs index 1715435..bf01192 100644 --- a/src/ConfigCat.Cli/Commands/Flags/FlagTargeting.cs +++ b/src/ConfigCat.Cli/Commands/Flags/FlagTargeting.cs @@ -51,9 +51,8 @@ public async Task AddTargetingRuleAsync(int? flagId, }; await this.ValidateAddModel(addTargetingRuleModel, environmentId, token); - - if (!addTargetingRuleModel.FlagValue.TryParseFlagValue(flag.SettingType, out var parsed)) - throw new ShowHelpException($"Flag value '{addTargetingRuleModel.FlagValue}' must respect the type '{flag.SettingType}'."); + + var parsed = addTargetingRuleModel.FlagValue.ToObjectValue(flag.SettingType); if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); @@ -108,8 +107,7 @@ public async Task UpdateTargetingRuleAsync(int? flagId, await this.ValidateAddModel(addTargetingRuleModel, environmentId, token, existing); - if (!addTargetingRuleModel.FlagValue.TryParseFlagValue(flag.SettingType, out var parsed)) - throw new ShowHelpException($"Flag value '{addTargetingRuleModel.FlagValue}' must respect the type '{flag.SettingType}'."); + var parsed = addTargetingRuleModel.FlagValue.ToObjectValue(flag.SettingType); if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); diff --git a/src/ConfigCat.Cli/Commands/Flags/FlagValue.cs b/src/ConfigCat.Cli/Commands/Flags/FlagValue.cs index c05d7c7..6ab9126 100644 --- a/src/ConfigCat.Cli/Commands/Flags/FlagValue.cs +++ b/src/ConfigCat.Cli/Commands/Flags/FlagValue.cs @@ -1,6 +1,5 @@ using ConfigCat.Cli.Services; using ConfigCat.Cli.Services.Api; -using ConfigCat.Cli.Services.Exceptions; using ConfigCat.Cli.Services.Json; using ConfigCat.Cli.Services.Rendering; using System; @@ -157,8 +156,7 @@ public async Task UpdateFlagValueAsync(int? flagId, string environmentId, s if (flagValue.IsEmpty()) flagValue = await prompt.GetStringAsync($"Value", token, value.Value.ToString()); - if (!flagValue.TryParseFlagValue(value.Setting.SettingType, out var parsed)) - throw new ShowHelpException($"Flag value '{flagValue}' must respect the type '{value.Setting.SettingType}'."); + var parsed = flagValue.ToObjectValue(flag.SettingType); if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); diff --git a/src/ConfigCat.Cli/Commands/Flags/V2/FlagPercentage.cs b/src/ConfigCat.Cli/Commands/Flags/V2/FlagPercentage.cs index 89ea99e..066c557 100644 --- a/src/ConfigCat.Cli/Commands/Flags/V2/FlagPercentage.cs +++ b/src/ConfigCat.Cli/Commands/Flags/V2/FlagPercentage.cs @@ -117,10 +117,22 @@ private async Task> GetPercentageOptions(UpdatePerce return percentageOptions.Select(po => new PercentageOptionModel { Percentage = po.Percentage, - Value = po.Value.ToFlagValue(flag.SettingType) + Value = po.Value.ToFlagValueWithVariation(flag.SettingType, flag.PredefinedVariations) }).ToList(); - var items = await prompt.GetRepeatedValuesAsync("Set percentage options", token, ["Percentage", "Value"]); + var items = await prompt.GetRepeatedValuesAsync("Set percentage options", token, [ + new RepeatedValuesDescriptor { Label = "Percentage" }, flag.PredefinedVariations.IsEmpty() + ? new RepeatedValuesDescriptor { Label = "Value"} + : new RepeatedValuesDescriptor + { + Label = "Choose variation", + Reader = async (p, l, t) => + { + var result =await p.ChooseFromListAsync(l, flag.PredefinedVariations, v => v.Name ?? v.Value.ToString(), t); + return result.PredefinedVariationId; + } + } + ]); if (items is null) throw new ShowHelpException($"Percentage options are required"); return items.Select(i => { @@ -128,7 +140,7 @@ private async Task> GetPercentageOptions(UpdatePerce return new PercentageOptionModel { Percentage = percentage, - Value = i[1].ToFlagValue(flag.SettingType), + Value = i[1].ToFlagValueWithVariation(flag.SettingType, flag.PredefinedVariations), }; }).ToList(); } diff --git a/src/ConfigCat.Cli/Commands/Flags/V2/FlagTargeting.cs b/src/ConfigCat.Cli/Commands/Flags/V2/FlagTargeting.cs index 6b56195..2db22b2 100644 --- a/src/ConfigCat.Cli/Commands/Flags/V2/FlagTargeting.cs +++ b/src/ConfigCat.Cli/Commands/Flags/V2/FlagTargeting.cs @@ -13,8 +13,11 @@ namespace ConfigCat.Cli.Commands.Flags.V2; -internal class FlagTargeting(IPrompt prompt, IFlagClient flagClient, - IWorkspaceLoader workspaceLoader, IFlagValueV2Client flagValueClient) +internal class FlagTargeting( + IPrompt prompt, + IFlagClient flagClient, + IWorkspaceLoader workspaceLoader, + IFlagValueV2Client flagValueClient) { public async Task AddUserTargetingRuleAsync(int? flagId, string environmentId, @@ -34,15 +37,17 @@ public async Task AddUserTargetingRuleAsync(int? flagId, if (environmentId.IsEmpty()) environmentId = (await workspaceLoader.LoadEnvironmentAsync(token, flag.ConfigId)).EnvironmentId; - + if (attribute.IsEmpty()) attribute = await prompt.GetStringAsync("Comparison attribute", token, "Identifier"); if (comparator.IsEmpty()) - comparator = (await prompt.ChooseFromListAsync("Choose comparator", Constants.UserComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; + comparator = (await prompt.ChooseFromListAsync("Choose comparator", Constants.UserComparatorTypes.ToList(), + c => $"{c.Key} [{c.Value}]", token)).Key; if (!Constants.UserComparatorTypes.Keys.Contains(comparator, StringComparer.OrdinalIgnoreCase)) - throw new ShowHelpException($"Comparator must be one of the following: {string.Join('|', Constants.UserComparatorTypes)}"); + throw new ShowHelpException( + $"Comparator must be one of the following: {string.Join('|', Constants.UserComparatorTypes)}"); var condition = new UserConditionModel { @@ -55,14 +60,15 @@ public async Task AddUserTargetingRuleAsync(int? flagId, if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); jsonPatchDocument.Add($"/targetingRules/-", rule); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); - + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); + return ExitCodes.Ok; } - + public async Task AddUserConditionAsync(int? flagId, string environmentId, int? rulePosition, @@ -82,29 +88,33 @@ public async Task AddUserConditionAsync(int? flagId, environmentId = (await workspaceLoader.LoadEnvironmentAsync(token, flag.ConfigId)).EnvironmentId; rulePosition ??= await PromptPosition("Targeting rule's position the condition should be added to", token); - + if (attribute.IsEmpty()) attribute = await prompt.GetStringAsync("Comparison attribute", token, "Identifier"); if (comparator.IsEmpty()) - comparator = (await prompt.ChooseFromListAsync("Choose comparator", Constants.UserComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; + comparator = (await prompt.ChooseFromListAsync("Choose comparator", Constants.UserComparatorTypes.ToList(), + c => $"{c.Key} [{c.Value}]", token)).Key; if (!Constants.UserComparatorTypes.Keys.Contains(comparator, StringComparer.OrdinalIgnoreCase)) - throw new ShowHelpException($"Comparator must be one of the following: {string.Join('|', Constants.UserComparatorTypes)}"); + throw new ShowHelpException( + $"Comparator must be one of the following: {string.Join('|', Constants.UserComparatorTypes)}"); var condition = new UserConditionModel { Comparator = comparator, ComparisonAttribute = attribute, ComparisonValue = await ParseComparisonValue(comparator, comparisonValue, token) }; - + if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); - jsonPatchDocument.Add($"/targetingRules/{rulePosition-1}/conditions/-", new ConditionModel { UserCondition = condition }); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); - + jsonPatchDocument.Add($"/targetingRules/{rulePosition - 1}/conditions/-", + new ConditionModel { UserCondition = condition }); + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); + return ExitCodes.Ok; } @@ -125,13 +135,15 @@ public async Task AddSegmentTargetingRuleAsync(int? flagId, if (environmentId.IsEmpty()) environmentId = (await workspaceLoader.LoadEnvironmentAsync(token, flag.ConfigId)).EnvironmentId; - + if (comparator.IsEmpty()) - comparator = (await prompt.ChooseFromListAsync("Choose comparator", Constants.SegmentComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; + comparator = (await prompt.ChooseFromListAsync("Choose comparator", + Constants.SegmentComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; if (!Constants.SegmentComparatorTypes.Keys.Contains(comparator, StringComparer.OrdinalIgnoreCase)) - throw new ShowHelpException($"Comparator must be one of the following: {string.Join('|', Constants.SegmentComparatorTypes)}"); - + throw new ShowHelpException( + $"Comparator must be one of the following: {string.Join('|', Constants.SegmentComparatorTypes)}"); + var condition = new SegmentConditionModel { Comparator = comparator }; if (!segmentId.IsEmpty()) { @@ -142,20 +154,21 @@ public async Task AddSegmentTargetingRuleAsync(int? flagId, var segment = await workspaceLoader.LoadSegmentAsync(token); condition.SegmentId = segment.SegmentId; } - + var rule = new TargetingRuleModel { Conditions = [new ConditionModel { SegmentCondition = condition }] }; await SetRuleThenPart(servedValue, percentageOptions, rule, flag, token); - + if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); jsonPatchDocument.Add($"/targetingRules/-", rule); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); - + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); + return ExitCodes.Ok; } - + public async Task AddSegmentConditionAsync(int? flagId, string environmentId, int? rulePosition, @@ -172,15 +185,17 @@ public async Task AddSegmentConditionAsync(int? flagId, if (environmentId.IsEmpty()) environmentId = (await workspaceLoader.LoadEnvironmentAsync(token, flag.ConfigId)).EnvironmentId; - + rulePosition ??= await PromptPosition("Targeting rule's position the condition should be added to", token); - + if (comparator.IsEmpty()) - comparator = (await prompt.ChooseFromListAsync("Choose comparator", Constants.SegmentComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; + comparator = (await prompt.ChooseFromListAsync("Choose comparator", + Constants.SegmentComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; if (!Constants.SegmentComparatorTypes.Keys.Contains(comparator, StringComparer.OrdinalIgnoreCase)) - throw new ShowHelpException($"Comparator must be one of the following: {string.Join('|', Constants.SegmentComparatorTypes)}"); - + throw new ShowHelpException( + $"Comparator must be one of the following: {string.Join('|', Constants.SegmentComparatorTypes)}"); + var condition = new SegmentConditionModel { Comparator = comparator }; if (!segmentId.IsEmpty()) { @@ -191,23 +206,25 @@ public async Task AddSegmentConditionAsync(int? flagId, var segment = await workspaceLoader.LoadSegmentAsync(token); condition.SegmentId = segment.SegmentId; } - + if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); - jsonPatchDocument.Add($"/targetingRules/{rulePosition-1}/conditions/-", new ConditionModel { SegmentCondition = condition }); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); - + jsonPatchDocument.Add($"/targetingRules/{rulePosition - 1}/conditions/-", + new ConditionModel { SegmentCondition = condition }); + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); + return ExitCodes.Ok; } - + public async Task AddPrerequisiteTargetingRuleAsync(int? flagId, string environmentId, string comparator, int? prerequisiteId, string prerequisiteValue, - string servedValue, + string servedValue, string reason, UpdatePercentageModel[] percentageOptions, CancellationToken token) @@ -220,13 +237,15 @@ public async Task AddPrerequisiteTargetingRuleAsync(int? flagId, if (environmentId.IsEmpty()) environmentId = (await workspaceLoader.LoadEnvironmentAsync(token, flag.ConfigId)).EnvironmentId; - + if (comparator.IsEmpty()) - comparator = (await prompt.ChooseFromListAsync("Choose comparator", Constants.PrerequisiteComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; + comparator = (await prompt.ChooseFromListAsync("Choose comparator", + Constants.PrerequisiteComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; if (!Constants.PrerequisiteComparatorTypes.Keys.Contains(comparator, StringComparer.OrdinalIgnoreCase)) - throw new ShowHelpException($"Comparator must be one of the following: {string.Join('|', Constants.PrerequisiteComparatorTypes)}"); - + throw new ShowHelpException( + $"Comparator must be one of the following: {string.Join('|', Constants.PrerequisiteComparatorTypes)}"); + var flags = (await flagClient.GetFlagsAsync(flag.ConfigId, token)).ToList(); var condition = new PrerequisiteFlagConditionModel { Comparator = comparator }; if (prerequisiteId is not null) @@ -239,7 +258,8 @@ public async Task AddPrerequisiteTargetingRuleAsync(int? flagId, { var filtered = flags.Where(f => f.SettingId != flag.SettingId).ToList(); if (filtered.Count == 0) throw new ShowHelpException("No other flags can be selected as prerequisite"); - var selected = await prompt.ChooseFromListAsync("Choose prerequisite", filtered, f => $"{f.Name} ({f.ConfigName})", token); + var selected = await prompt.ChooseFromListAsync("Choose prerequisite", filtered, + f => $"{f.Name} ({f.ConfigName})", token); if (selected is null) throw new ShowHelpException("Prerequisite flag is required"); condition.PrerequisiteSettingId = selected.SettingId; } @@ -247,28 +267,37 @@ public async Task AddPrerequisiteTargetingRuleAsync(int? flagId, var prerequisiteFlag = flags.First(f => f.SettingId == condition.PrerequisiteSettingId); if (!prerequisiteValue.IsEmpty()) { - condition.PrerequisiteComparisonValue = prerequisiteValue.ToFlagValue(prerequisiteFlag.SettingType); + condition.PrerequisiteComparisonValue = + prerequisiteValue.ToFlagValueWithVariation(prerequisiteFlag.SettingType, + prerequisiteFlag.PredefinedVariations); } else { - var val = await prompt.GetStringAsync("Prerequisite flag value", token); + var val = prerequisiteFlag.PredefinedVariations.IsEmpty() + ? await prompt.GetStringAsync("Prerequisite flag value", token) + : (await prompt.ChooseFromListAsync("Choose variation for prerequisite flag", + prerequisiteFlag.PredefinedVariations, p => p.Name ?? p.Value.ToString(), token)) + .PredefinedVariationId; if (val is null) throw new ShowHelpException($"Prerequisite flag value is required"); - condition.PrerequisiteComparisonValue = val.ToFlagValue(prerequisiteFlag.SettingType); + condition.PrerequisiteComparisonValue = val.ToFlagValueWithVariation(prerequisiteFlag.SettingType, + prerequisiteFlag.PredefinedVariations); } - - var rule = new TargetingRuleModel { Conditions = [new ConditionModel { PrerequisiteFlagCondition = condition }] }; + + var rule = new TargetingRuleModel + { Conditions = [new ConditionModel { PrerequisiteFlagCondition = condition }] }; await SetRuleThenPart(servedValue, percentageOptions, rule, flag, token); - + if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); jsonPatchDocument.Add($"/targetingRules/-", rule); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); - + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); + return ExitCodes.Ok; } - + public async Task AddPrerequisiteConditionAsync(int? flagId, string environmentId, int? rulePosition, @@ -286,15 +315,17 @@ public async Task AddPrerequisiteConditionAsync(int? flagId, if (environmentId.IsEmpty()) environmentId = (await workspaceLoader.LoadEnvironmentAsync(token, flag.ConfigId)).EnvironmentId; - + rulePosition ??= await PromptPosition("Targeting rule's position the condition should be added to", token); - + if (comparator.IsEmpty()) - comparator = (await prompt.ChooseFromListAsync("Choose comparator", Constants.PrerequisiteComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; + comparator = (await prompt.ChooseFromListAsync("Choose comparator", + Constants.PrerequisiteComparatorTypes.ToList(), c => $"{c.Key} [{c.Value}]", token)).Key; if (!Constants.PrerequisiteComparatorTypes.Keys.Contains(comparator, StringComparer.OrdinalIgnoreCase)) - throw new ShowHelpException($"Comparator must be one of the following: {string.Join('|', Constants.PrerequisiteComparatorTypes)}"); - + throw new ShowHelpException( + $"Comparator must be one of the following: {string.Join('|', Constants.PrerequisiteComparatorTypes)}"); + var flags = (await flagClient.GetFlagsAsync(flag.ConfigId, token)).ToList(); var condition = new PrerequisiteFlagConditionModel { Comparator = comparator }; if (prerequisiteId is not null) @@ -307,7 +338,8 @@ public async Task AddPrerequisiteConditionAsync(int? flagId, { var filtered = flags.Where(f => f.SettingId != flag.SettingId).ToList(); if (filtered.Count == 0) throw new ShowHelpException("No other flags can be selected as prerequisite"); - var selected = await prompt.ChooseFromListAsync("Choose prerequisite", filtered, f => $"{f.Name} ({f.ConfigName})", token); + var selected = await prompt.ChooseFromListAsync("Choose prerequisite", filtered, + f => $"{f.Name} ({f.ConfigName})", token); if (selected is null) throw new ShowHelpException("Prerequisite flag is required"); condition.PrerequisiteSettingId = selected.SettingId; } @@ -315,25 +347,34 @@ public async Task AddPrerequisiteConditionAsync(int? flagId, var prerequisiteFlag = flags.First(f => f.SettingId == condition.PrerequisiteSettingId); if (!prerequisiteValue.IsEmpty()) { - condition.PrerequisiteComparisonValue = prerequisiteValue.ToFlagValue(prerequisiteFlag.SettingType); + condition.PrerequisiteComparisonValue = + prerequisiteValue.ToFlagValueWithVariation(prerequisiteFlag.SettingType, + prerequisiteFlag.PredefinedVariations); } else { - var val = await prompt.GetStringAsync("Prerequisite flag value", token); + var val = prerequisiteFlag.PredefinedVariations.IsEmpty() + ? await prompt.GetStringAsync("Prerequisite flag value", token) + : (await prompt.ChooseFromListAsync("Choose variation for prerequisite flag", + prerequisiteFlag.PredefinedVariations, p => p.Name ?? p.Value.ToString(), token)) + .PredefinedVariationId; if (val is null) throw new ShowHelpException($"Prerequisite flag value is required"); - condition.PrerequisiteComparisonValue = val.ToFlagValue(prerequisiteFlag.SettingType); + condition.PrerequisiteComparisonValue = val.ToFlagValueWithVariation(prerequisiteFlag.SettingType, + prerequisiteFlag.PredefinedVariations); } - + if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); - jsonPatchDocument.Add($"/targetingRules/{rulePosition-1}/conditions/-", new ConditionModel { PrerequisiteFlagCondition = condition }); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); - + jsonPatchDocument.Add($"/targetingRules/{rulePosition - 1}/conditions/-", + new ConditionModel { PrerequisiteFlagCondition = condition }); + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); + return ExitCodes.Ok; } - + public async Task DeleteRuleAsync(int? flagId, string environmentId, int? rulePosition, @@ -348,19 +389,20 @@ public async Task DeleteRuleAsync(int? flagId, if (environmentId.IsEmpty()) environmentId = (await workspaceLoader.LoadEnvironmentAsync(token, flag.ConfigId)).EnvironmentId; - + rulePosition ??= await PromptPosition("Targeting rule's position to remove", token); - + if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); - jsonPatchDocument.Remove($"/targetingRules/{rulePosition-1}"); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); - + jsonPatchDocument.Remove($"/targetingRules/{rulePosition - 1}"); + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); + return ExitCodes.Ok; } - + public async Task DeleteConditionAsync(int? flagId, string environmentId, int? rulePosition, @@ -376,21 +418,23 @@ public async Task DeleteConditionAsync(int? flagId, if (environmentId.IsEmpty()) environmentId = (await workspaceLoader.LoadEnvironmentAsync(token, flag.ConfigId)).EnvironmentId; - + rulePosition ??= await PromptPosition("Targeting rule's position", token); conditionPosition ??= await PromptPosition("Condition's position to remove", token); - + if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); - jsonPatchDocument.Remove($"/targetingRules/{rulePosition-1}/conditions/{conditionPosition-1}"); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); - + jsonPatchDocument.Remove($"/targetingRules/{rulePosition - 1}/conditions/{conditionPosition - 1}"); + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); + return ExitCodes.Ok; } - - public async Task MoveTargetingRuleAsync(int? flagId, string environmentId, int? from, int? to, string reason, CancellationToken token) + + public async Task MoveTargetingRuleAsync(int? flagId, string environmentId, int? from, int? to, string reason, + CancellationToken token) { var flag = flagId switch { @@ -406,14 +450,15 @@ public async Task MoveTargetingRuleAsync(int? flagId, string environmentId, if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); - jsonPatchDocument.Move($"/targetingRules/{from-1}", $"/targetingRules/{to-1}"); + jsonPatchDocument.Move($"/targetingRules/{from - 1}", $"/targetingRules/{to - 1}"); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); return ExitCodes.Ok; } - + public async Task UpdateRuleServedValueAsync(int? flagId, string environmentId, int? rulePosition, @@ -432,24 +477,26 @@ public async Task UpdateRuleServedValueAsync(int? flagId, environmentId = (await workspaceLoader.LoadEnvironmentAsync(token, flag.ConfigId)).EnvironmentId; rulePosition ??= await PromptPosition("Targeting rule's position to update", token); - + var value = await flagValueClient.GetValueAsync(flag.SettingId, environmentId, token); - var rule = value.TargetingRules?.ElementAtOrDefault(rulePosition.Value-1); + var rule = value.TargetingRules?.ElementAtOrDefault(rulePosition.Value - 1); if (rule is null) throw new ShowHelpException($"Targeting rule in position '{rulePosition}' not found"); - + await SetRuleThenPart(servedValue, percentageOptions, rule, flag, token); - + if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); - + var jsonPatchDocument = new JsonPatchDocument(); - jsonPatchDocument.Replace($"/targetingRules/{rulePosition-1}", rule); - await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); - + jsonPatchDocument.Replace($"/targetingRules/{rulePosition - 1}", rule); + await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, + token); + return ExitCodes.Ok; } - private async Task ParseComparisonValue(string comparator, string[] comparisonValue, CancellationToken token) + private async Task ParseComparisonValue(string comparator, string[] comparisonValue, + CancellationToken token) { if (!comparisonValue.IsEmpty()) { @@ -458,13 +505,16 @@ private async Task ParseComparisonValue(string comparator, var compVal = comparisonValue[0]; if (!comparator.IsNumberComparator()) return new ComparisonValueModel { StringValue = compVal }; - if (!double.TryParse(compVal, out var d)) throw new ShowHelpException($"Comparison value '{compVal}' is not a valid number"); + if (!double.TryParse(compVal, out var d)) + throw new ShowHelpException($"Comparison value '{compVal}' is not a valid number"); return new ComparisonValueModel { DoubleValue = d }; } - + if (comparator.IsListComparator()) { - var items = await prompt.GetRepeatedValuesAsync("Set values for comparison value list", token, ["Value", "Hint"]); + var items = await prompt.GetRepeatedValuesAsync("Set values for comparison value list", token, [ + new RepeatedValuesDescriptor { Label = "Value" }, new RepeatedValuesDescriptor { Label = "Hint" } + ]); if (items is null) throw new ShowHelpException($"Comparison value is required"); return new ComparisonValueModel { @@ -479,7 +529,8 @@ private async Task ParseComparisonValue(string comparator, var cv = await prompt.GetStringAsync("Comparison value", token); if (cv is null) throw new ShowHelpException($"Comparison value is required"); if (!comparator.IsNumberComparator()) return new ComparisonValueModel { StringValue = cv }; - if (!double.TryParse(cv, out var parsed)) throw new ShowHelpException($"Comparison value '{cv}' is not a valid number"); + if (!double.TryParse(cv, out var parsed)) + throw new ShowHelpException($"Comparison value '{cv}' is not a valid number"); return new ComparisonValueModel { DoubleValue = parsed }; } @@ -492,7 +543,8 @@ private List ToListModel(string[] comparisonValues) var expression = comparisonValues[i]; var indexOfSeparator = expression.IndexOf(':'); if (indexOfSeparator == -1) - throw new ShowHelpException($"The expression `{expression}` is invalid. Required format: :"); + throw new ShowHelpException( + $"The expression `{expression}` is invalid. Required format: :"); var value = expression[..indexOfSeparator]; var hint = expression[(indexOfSeparator + 1)..]; @@ -502,50 +554,70 @@ private List ToListModel(string[] comparisonValues) result.Add(new ComparisonValueListModel { Value = value, Hint = hint }); } + return result; } - + private async Task SetRuleThenPart(string servedValue, UpdatePercentageModel[] percentageOptions, TargetingRuleModel rule, FlagModel flag, CancellationToken token) { if (percentageOptions.IsEmpty() && servedValue.IsEmpty()) { - var selected = await prompt.ChooseFromListAsync("Choose the targeting rule's THEN part", ["value", "percentage"], c => c, token); - if (selected == "value") + var selected = await prompt.ChooseFromListAsync("Choose the targeting rule's THEN part", + ["Value", "Percentage"], c => c, token); + if (selected == "Value") { - var val = await prompt.GetStringAsync("Served value", token); - if (val is null) throw new ShowHelpException($"Served value is required"); - rule.Value = val.ToFlagValue(flag.SettingType); + var val = flag.PredefinedVariations.IsEmpty() + ? await prompt.GetStringAsync("Served value", token) + : (await prompt.ChooseFromListAsync("Choose variation for served value", + flag.PredefinedVariations, p => p.Name ?? p.Value.ToString(), token)) + .PredefinedVariationId; + if (val is null) throw new ShowHelpException("Served value is required"); + rule.Value = val.ToFlagValueWithVariation(flag.SettingType, flag.PredefinedVariations); rule.PercentageOptions = null; } else { - var items = await prompt.GetRepeatedValuesAsync("Set percentage options", token, ["Percentage", "Value"]); + var items = await prompt.GetRepeatedValuesAsync("Set percentage options", token, [ + new RepeatedValuesDescriptor { Label = "Percentage" }, flag.PredefinedVariations.IsEmpty() + ? new RepeatedValuesDescriptor { Label = "Value"} + : new RepeatedValuesDescriptor + { + Label = "Choose variation", + Reader = async (p, l, t) => + { + var result =await p.ChooseFromListAsync(l, flag.PredefinedVariations, v => v.Name ?? v.Value.ToString(), t); + return result.PredefinedVariationId; + } + } + ]); if (items is null) throw new ShowHelpException($"Percentage options are required"); rule.PercentageOptions = items.Select(i => { - if (!int.TryParse(i[0], out var percentage) || percentage < 0) throw new ShowHelpException($"Percentage value '{i[0]}' is invalid"); + if (!int.TryParse(i[0], out var percentage) || percentage < 0) + throw new ShowHelpException($"Percentage value '{i[0]}' is invalid"); + return new PercentageOptionModel { Percentage = percentage, - Value = i[1].ToFlagValue(flag.SettingType), + Value = i[1].ToFlagValueWithVariation(flag.SettingType, flag.PredefinedVariations), }; }).ToList(); rule.Value = null; } - } + } else if (!percentageOptions.IsEmpty()) { rule.PercentageOptions = percentageOptions.Select(po => new PercentageOptionModel { Percentage = po.Percentage, - Value = po.Value.ToFlagValue(flag.SettingType) + Value = po.Value.ToFlagValueWithVariation(flag.SettingType, flag.PredefinedVariations) }).ToList(); rule.Value = null; } else { - rule.Value = servedValue.ToFlagValue(flag.SettingType); + rule.Value = servedValue.ToFlagValueWithVariation(flag.SettingType, flag.PredefinedVariations); rule.PercentageOptions = null; } } @@ -553,7 +625,8 @@ private async Task SetRuleThenPart(string servedValue, UpdatePercentageModel[] p private async Task PromptPosition(string label, CancellationToken token) { var position = await prompt.GetStringAsync(label, token, "1"); - if (!int.TryParse(position, out var parsed) || parsed < 1) throw new ShowHelpException($"Position '{position}' is invalid"); + if (!int.TryParse(position, out var parsed) || parsed < 1) + throw new ShowHelpException($"Position '{position}' is invalid"); return parsed; } } \ No newline at end of file diff --git a/src/ConfigCat.Cli/Commands/Flags/V2/FlagValue.cs b/src/ConfigCat.Cli/Commands/Flags/V2/FlagValue.cs index b474a81..730cadb 100644 --- a/src/ConfigCat.Cli/Commands/Flags/V2/FlagValue.cs +++ b/src/ConfigCat.Cli/Commands/Flags/V2/FlagValue.cs @@ -1,6 +1,5 @@ using ConfigCat.Cli.Services; using ConfigCat.Cli.Services.Api; -using ConfigCat.Cli.Services.Exceptions; using ConfigCat.Cli.Services.Json; using ConfigCat.Cli.Services.Rendering; using System; @@ -137,7 +136,7 @@ public async Task ShowValueAsync(int? flagId, bool json, CancellationToken output.WriteDarkGray($" If ") .WriteCyan($"{preReq?.Key} ") .WriteYellow($"{comparatorName} ") - .WriteCyan($"{condition.PrerequisiteFlagCondition.PrerequisiteComparisonValue.ToSingle(preReq?.SettingType)}"); + .WriteCyan($"{condition.PrerequisiteFlagCondition.PrerequisiteComparisonValue.ToSingle(preReq)}"); } else { @@ -146,7 +145,7 @@ public async Task ShowValueAsync(int? flagId, bool json, CancellationToken .WriteDarkGray($" && ") .WriteCyan($"{preReq?.Key} ") .WriteYellow($"{comparatorName} ") - .WriteCyan($"{condition.PrerequisiteFlagCondition.PrerequisiteComparisonValue.ToSingle(preReq?.SettingType)}"); + .WriteCyan($"{condition.PrerequisiteFlagCondition.PrerequisiteComparisonValue.ToSingle(preReq)}"); } } } @@ -154,7 +153,7 @@ public async Task ShowValueAsync(int? flagId, bool json, CancellationToken if (targeting.Value is not null) { - output.WriteLine().WriteDarkGray($"| Then: ").WriteMagenta(targeting.Value.ToSingle(flag.SettingType).ToString()); + output.WriteLine().WriteDarkGray($"| Then: ").WriteMagenta(targeting.Value.ToSingle(flag).ToString()); } else if (targeting.PercentageOptions.Count > 0) { @@ -173,7 +172,7 @@ public async Task ShowValueAsync(int? flagId, bool json, CancellationToken .WriteDarkGray(" attribute"); } output.WriteDarkGray(" -> ") - .WriteMagenta(percentage.Value.ToSingle(flag.SettingType).ToString()); + .WriteMagenta(percentage.Value.ToSingle(flag).ToString()); } } else @@ -190,7 +189,7 @@ public async Task ShowValueAsync(int? flagId, bool json, CancellationToken .WriteDarkGray(" attribute"); } output.WriteDarkGray(" -> ") - .WriteMagenta(percentage.Value.ToSingle(flag.SettingType).ToString()); + .WriteMagenta(percentage.Value.ToSingle(flag).ToString()); } else { @@ -204,7 +203,7 @@ public async Task ShowValueAsync(int? flagId, bool json, CancellationToken .WriteDarkGray(" attribute"); } output.WriteDarkGray(" -> ") - .WriteMagenta(percentage.Value.ToSingle(flag.SettingType).ToString()); + .WriteMagenta(percentage.Value.ToSingle(flag).ToString()); } } } @@ -220,7 +219,7 @@ public async Task ShowValueAsync(int? flagId, bool json, CancellationToken output.WriteLine() .WriteDarkGray($"| Default: ") - .WriteMagenta(value.DefaultValue.ToSingle(flag.SettingType).ToString()) + .WriteMagenta(value.DefaultValue.ToSingle(flag).ToString()) .WriteLine() .WriteDarkGray(new string('-', separatorLength)) .WriteLine(); @@ -241,16 +240,25 @@ public async Task UpdateFlagValueAsync(int? flagId, string environmentId, s var value = await flagValueClient.GetValueAsync(flag.SettingId, environmentId, token); if (flagValue.IsEmpty()) - flagValue = await prompt.GetStringAsync($"Value", token, value.DefaultValue.ToSingle(flag.SettingType).ToString()); + { + if (!flag.PredefinedVariations.IsEmpty()) + { + flagValue = (await prompt.ChooseFromListAsync("Choose variation", flag.PredefinedVariations, + v => v.Name ?? v.Value.ToString(), token)).PredefinedVariationId; + } + else + { + flagValue = await prompt.GetStringAsync("Value", token, value.DefaultValue.ToSingle(flag).ToString()); + } + } - if (!flagValue.TryParseFlagValue(value.Setting.SettingType, out var parsed)) - throw new ShowHelpException($"Flag value '{flagValue}' must respect the type '{value.Setting.SettingType}'."); + var parsed = flagValue.ToFlagValueWithVariation(flag.SettingType, flag.PredefinedVariations); if (await workspaceLoader.NeedsReasonAsync(environmentId, token) && reason.IsEmpty()) reason = await prompt.GetStringAsync("Mandatory reason", token); var jsonPatchDocument = new JsonPatchDocument(); - jsonPatchDocument.Replace($"/defaultValue/{flag.SettingType.ToValuePropertyName()}", parsed); + jsonPatchDocument.Replace($"/defaultValue", parsed); await flagValueClient.UpdateValueAsync(flag.SettingId, environmentId, reason, jsonPatchDocument.Operations, token); return ExitCodes.Ok; diff --git a/src/ConfigCat.Cli/Commands/Flags/V2/Variation.cs b/src/ConfigCat.Cli/Commands/Flags/V2/Variation.cs new file mode 100644 index 0000000..41df627 --- /dev/null +++ b/src/ConfigCat.Cli/Commands/Flags/V2/Variation.cs @@ -0,0 +1,137 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ConfigCat.Cli.Models.Api; +using ConfigCat.Cli.Services; +using ConfigCat.Cli.Services.Api; +using ConfigCat.Cli.Services.Exceptions; +using ConfigCat.Cli.Services.Rendering; + +namespace ConfigCat.Cli.Commands.Flags.V2; + +internal class Variation( + IFlagClient flagClient, + IVariationClient variationClient, + IWorkspaceLoader workspaceLoader, + IPrompt prompt, + IOutput output) +{ + public async Task ListAsync(int? flagId, bool json, CancellationToken token) + { + var flag = flagId is null + ? await workspaceLoader.LoadFlagAsync(token) + : await flagClient.GetFlagAsync(flagId.Value, token); + + if (json) + { + output.RenderJson(flag.PredefinedVariations); + return ExitCodes.Ok; + } + + var itemsToRender = flag.PredefinedVariations.Select(f => new + { + Id = f.PredefinedVariationId, + f.Name, + Hint = f.Hint.TrimToFitColumn(), + f.Value, + }); + output.RenderTable(itemsToRender); + + return ExitCodes.Ok; + } + + public async Task CreateAsync(int? flagId, string name, string hint, string servedValue, CancellationToken token) + { + var flag = flagId is null + ? await workspaceLoader.LoadFlagAsync(token) + : await flagClient.GetFlagAsync(flagId.Value, token); + + if (name.IsEmpty()) + name = await prompt.GetStringAsync("Name", token); + + if (hint.IsEmpty()) + hint = await prompt.GetStringAsync("Hint", token); + + if (servedValue.IsEmpty()) + servedValue = await prompt.GetStringAsync($"Served value", token); + + var parsed = servedValue.ToFlagValue(flag.SettingType); + + var updated = flag.PredefinedVariations.ToList(); + updated.Add(new VariationModel + { + Name = name, + Hint = hint, + Value = parsed + }); + + var result = await variationClient.UpdateVariationsAsync(flag.SettingId, updated, token); + var created = result.PredefinedVariations.FirstOrDefault(e => e.Name == name && e.Hint == hint && e.Value.Equals(parsed)); + output.Write(created?.PredefinedVariationId); + return ExitCodes.Ok; + } + + public async Task UpdateAsync(int? flagId, string predefinedVariationId, string name, string hint, string servedValue, CancellationToken token) + { + var flag = flagId is null + ? await workspaceLoader.LoadFlagAsync(token) + : await flagClient.GetFlagAsync(flagId.Value, token); + + VariationModel selected; + if (predefinedVariationId.IsEmpty()) + { + selected = await prompt.ChooseFromListAsync("Choose variation", flag.PredefinedVariations, e => e.Name ?? e.Value.ToString(), token); + } + else + { + selected = flag.PredefinedVariations.FirstOrDefault(e => e.PredefinedVariationId == predefinedVariationId); + } + + if (selected == null) + throw new ShowHelpException($"Required option --predefined-variation-id is missing."); + + if (name.IsEmpty()) + name = await prompt.GetStringAsync("Name", token, selected.Name); + + if (hint.IsEmpty()) + hint = await prompt.GetStringAsync("Hint", token, selected.Hint); + + if (servedValue.IsEmpty()) + servedValue = await prompt.GetStringAsync($"Served value", token, selected.Value.ToString()); + + var parsed = servedValue.ToFlagValue(flag.SettingType); + + selected.Name = name; + selected.Hint = hint; + selected.Value = parsed; + + await variationClient.UpdateVariationsAsync(flag.SettingId, flag.PredefinedVariations, token); + + return ExitCodes.Ok; + } + + public async Task DeleteAsync(int? flagId, string predefinedVariationId, CancellationToken token) + { + var flag = flagId is null + ? await workspaceLoader.LoadFlagAsync(token) + : await flagClient.GetFlagAsync(flagId.Value, token); + + VariationModel selected; + if (predefinedVariationId.IsEmpty()) + { + selected = await prompt.ChooseFromListAsync("Choose variation", flag.PredefinedVariations, e => e.Name ?? e.Value.ToString(), token); + } + else + { + selected = flag.PredefinedVariations.FirstOrDefault(e => e.PredefinedVariationId == predefinedVariationId); + } + if (selected == null) + throw new ShowHelpException($"Required option --predefined-variation-id is missing."); + + var updated = flag.PredefinedVariations.Where(e => e.PredefinedVariationId != selected.PredefinedVariationId).ToList(); + await variationClient.UpdateVariationsAsync(flag.SettingId, updated, token); + + return ExitCodes.Ok; + } +} \ No newline at end of file diff --git a/src/ConfigCat.Cli/Options/PredefinedVariationOption.cs b/src/ConfigCat.Cli/Options/PredefinedVariationOption.cs new file mode 100644 index 0000000..981a374 --- /dev/null +++ b/src/ConfigCat.Cli/Options/PredefinedVariationOption.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.CommandLine; +using System.Linq; + +namespace ConfigCat.Cli.Options; + +internal class FlagPredefinedVariationOption : Option +{ + public FlagPredefinedVariationOption() : base(["--predefined-variations", "-pv"], argumentResult => + { + var length = argumentResult.Tokens.Count; + if (length == 0) + return []; + + var result = new PredefinedVariationOption[length]; + for (var i = 0; i < length; i++) + { + var expression = argumentResult.Tokens.ElementAt(i).Value; + var indexOfSeparator = expression.IndexOf(':'); + if (indexOfSeparator == -1) + { + var value = expression; + if (value.IsEmpty()) + { + argumentResult.ErrorMessage = $"The part of the expression `{expression}` is invalid."; + return null; + } + + result[i] = new PredefinedVariationOption { Value = value }; + } + else + { + var name = expression[..indexOfSeparator]; + var value = expression[(indexOfSeparator + 1)..]; + + if (name.IsEmpty()) + { + argumentResult.ErrorMessage = $"The part of the expression `{expression}` is invalid."; + return null; + } + + if (value.IsEmpty()) + { + argumentResult.ErrorMessage = $"The part of the expression `{expression}` is invalid."; + return null; + } + + result[i] = new PredefinedVariationOption { Name = name, Value = value }; + } + } + + return result; + }, false, "Predefined variations of the Feature Flag or Setting. Format: `` or `:`.") + { + } +} + +internal class PredefinedVariationOption +{ + public string Name { get; init; } + + public string Value { get; init; } +} \ No newline at end of file diff --git a/test/integ.ps1 b/test/integ.ps1 index 6167bf4..9f0b61b 100644 --- a/test/integ.ps1 +++ b/test/integ.ps1 @@ -636,6 +636,147 @@ Describe "Flag value / Rule Tests V2" { } } +Describe "Flag value / Rule Tests V2 Predefined Variations" { + BeforeAll { + $configV2PredefName = "CLI-IntegTest-Config-V2-Predef" + $configV2PredefId = Invoke-ConfigCat "config", "create", "-p", $productId, "-n", $configV2PredefName, "-e", "v2" + Invoke-ConfigCat "config", "ls", "-p", $productId | Should -Match ([regex]::Escape($configV2PredefName)) + } + + AfterAll { + Invoke-ConfigCat "config", "rm", "-i", $configV2PredefId + Invoke-ConfigCat "config", "ls", "-p", $productId | Should -Not -Match ([regex]::Escape($configV2PredefId)) + } + + BeforeEach { + $flagV2PredefId = Invoke-ConfigCat "flag-v2", "create", "-c", $configV2PredefId, "-n", "Bool-Flag", "-k", "bool_flag", "-H", "hint", "-t", "boolean", "-pv", "A1:true", "B1:false", "-iv", "false" + $flagV2PredefId2 = Invoke-ConfigCat "flag-v2", "create", "-c", $configV2PredefId, "-n", "Bool-Flag", "-k", "bool_flag2", "-H", "hint", "-t", "boolean", "-pv", "A2:true", "B2:false", "-iv", "false" + $flagV2PredefId3 = Invoke-ConfigCat "flag-v2", "create", "-c", $configV2PredefId, "-n", "String-Flag", "-k", "string_flag", "-H", "hint", "-t", "string", "-pv", "A3:a3", "B3:b3", "-iv", "b3" + } + + AfterEach { + Invoke-ConfigCat "flag-v2", "rm", "-i", $flagV2PredefId + Invoke-ConfigCat "flag-v2", "rm", "-i", $flagV2PredefId2 + Invoke-ConfigCat "flag-v2", "rm", "-i", $flagV2PredefId3 + } + + It "Update Value" { + Invoke-ConfigCat "flag-v2", "value", "update", "-i", $flagV2PredefId, "-e", $environmentId, "-f", "B1" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("Default: B1")) + } + + It "Create user targeting rule" { + Invoke-ConfigCat "flag-v2", "targeting", "rule", "cr", "u", "-i", $flagV2PredefId, "-e", $environmentId, "-a", "ID", "-c", "isOneOf", "-cv", "id1:user1", "id2:user2", "-sv", "A1" + Invoke-ConfigCat "flag-v2", "targeting", "rule", "cr", "u", "-i", $flagV2PredefId, "-e", $environmentId, "-a", "EMAIL", "-c", "textEquals", "-cv", "example.com", "-sv", "A1" + Invoke-ConfigCat "flag-v2", "targeting", "rule", "cr", "u", "-i", $flagV2PredefId, "-e", $environmentId, "-a", "VERSION", "-c", "isNotOneOf", "-cv", "1.2.6:", "1.2.8:", "-sv", "A1" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If ID IS ONE OF [2 items]")) + $result | Should -Match ([regex]::Escape("2. If EMAIL EQUALS example.com")) + $result | Should -Match ([regex]::Escape("3. If VERSION IS NOT ONE OF [2 items]")) + } + + It "Create segment targeting rule" { + Invoke-ConfigCat "flag-v2", "targeting", "rule", "cr", "sg", "-i", $flagV2PredefId, "-e", $environmentId, "-si", $segmentId, "-c", "isNotIn", "-sv", "A1" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If IS NOT IN SEGMENT CLI-IntegTest-Segment")) + $result | Should -Match ([regex]::Escape("Then: A1")) + } + + It "Create prerequisite targeting rule" { + Invoke-ConfigCat "flag-v2", "targeting", "rule", "cr", "pr", "-i", $flagV2PredefId, "-e", $environmentId, "-c", "equals", "-pi", $flagV2PredefId2, "-pv", "A2", "-sv", "A1" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If bool_flag2 EQUALS A2")) + $result | Should -Match ([regex]::Escape("Then: A1")) + } + + It "Remove targeting rule" { + Invoke-ConfigCat "flag-v2", "targeting", "rule", "cr", "u", "-i", $flagV2PredefId, "-e", $environmentId, "-a", "ID", "-c", "isOneOf", "-cv", "id1:user1", "id2:user2", "-sv", "A1" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If ID IS ONE OF [2 items]")) + Invoke-ConfigCat "flag-v2", "targeting", "rule", "rm", "-i", $flagV2PredefId, "-e", $environmentId, "-rp", "1", "-v" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Not -Match ([regex]::Escape("1. If ID IS ONE OF [2 items]")) + } + + It "Update targeting rule's served value" { + Invoke-ConfigCat "flag-v2", "targeting", "rule", "cr", "u", "-i", $flagV2PredefId, "-e", $environmentId, "-a", "ID", "-c", "isOneOf", "-cv", "id1:user1", "id2:user2", "-sv", "A1" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If ID IS ONE OF [2 items]")) + $result | Should -Match ([regex]::Escape("Then: A1")) + Invoke-ConfigCat "flag-v2", "targeting", "rule", "usv", "-i", $flagV2PredefId, "-e", $environmentId, "-rp", "1", "-po", "30:A1", "70:B1" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If ID IS ONE OF [2 items]")) + $result | Should -Match ([regex]::Escape("30% -> A1")) + $result | Should -Match ([regex]::Escape("70% -> B1")) + } + + It "Add/remove conditions" { + Invoke-ConfigCat "flag-v2", "targeting", "rule", "cr", "u", "-i", $flagV2PredefId, "-e", $environmentId, "-a", "ID", "-c", "isOneOf", "-cv", "id1:user1", "id2:user2", "-sv", "A1" + Invoke-ConfigCat "flag-v2", "targeting", "c", "a", "u", "-i", $flagV2PredefId, "-e", $environmentId, "-rp", "1", "-a", "EMAIL", "-c", "textEquals", "-cv", "test@example.com" + Invoke-ConfigCat "flag-v2", "targeting", "c", "a", "sg", "-i", $flagV2PredefId, "-e", $environmentId, "-rp", "1", "-si", $segmentId, "-c", "isIn" + Invoke-ConfigCat "flag-v2", "targeting", "c", "a", "pr", "-i", $flagV2PredefId, "-e", $environmentId, "-rp", "1", "-c", "equals", "-pi", $flagV2PredefId2, "-pv", "A2" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If ID IS ONE OF [2 items]")) + $result | Should -Match ([regex]::Escape("&& EMAIL EQUALS test@example.com")) + $result | Should -Match ([regex]::Escape("&& IS IN SEGMENT $segmentName")) + $result | Should -Match ([regex]::Escape("&& bool_flag2 EQUALS A2")) + Invoke-ConfigCat "flag-v2", "targeting", "c", "rm", "-i", $flagV2PredefId, "-e", $environmentId, "-rp", "1", "-cp", "4" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If ID IS ONE OF [2 items]")) + $result | Should -Match ([regex]::Escape("&& EMAIL EQUALS test@example.com")) + $result | Should -Match ([regex]::Escape("&& IS IN SEGMENT $segmentName")) + $result | Should -Not -Match ([regex]::Escape("&& bool_flag2 EQUALS A2")) + Invoke-ConfigCat "flag-v2", "targeting", "c", "rm", "-i", $flagV2PredefId, "-e", $environmentId, "-rp", "1", "-cp", "3" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If ID IS ONE OF [2 items]")) + $result | Should -Match ([regex]::Escape("&& EMAIL EQUALS test@example.com")) + $result | Should -Not -Match ([regex]::Escape("&& IS IN SEGMENT $segmentName")) + Invoke-ConfigCat "flag-v2", "targeting", "c", "rm", "-i", $flagV2PredefId, "-e", $environmentId, "-rp", "1", "-cp", "2" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. If ID IS ONE OF [2 items]")) + $result | Should -Not -Match ([regex]::Escape("&& EMAIL EQUALS test@example.com")) + } + + It "Update percentage options" { + Invoke-ConfigCat "flag-v2", "targeting", "%", "up", "-i", $flagV2PredefId, "-e", $environmentId, "-po", "40:A1", "60:B1" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("40% -> A1")) + $result | Should -Match ([regex]::Escape("60% -> B1")) + Invoke-ConfigCat "flag-v2", "targeting", "%", "clr", "-i", $flagV2PredefId, "-e", $environmentId + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Not -Match ([regex]::Escape("40% -> A1")) + $result | Should -Not -Match ([regex]::Escape("60% -> B1")) + } + + It "Update percentage attribute" { + Invoke-ConfigCat "flag-v2", "targeting", "%", "at", "-i", $flagV2PredefId, "-e", $environmentId, "-n", "Custom1" + Invoke-ConfigCat "flag-v2", "targeting", "%", "up", "-i", $flagV2PredefId, "-e", $environmentId, "-po", "40:A1", "60:B1" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("40% of Custom1 attribute -> A1")) + $result | Should -Match ([regex]::Escape("60% of Custom1 attribute -> B1")) + Invoke-ConfigCat "flag-v2", "targeting", "c", "a", "sg", "-i", $flagV2PredefId, "-e", $environmentId, "-rp", "1", "-si", $segmentId, "-c", "isIn" + $result = Invoke-ConfigCat "flag-v2", "value", "show", "-i", $flagV2PredefId + $result | Should -Match ([regex]::Escape("1. IF IS IN SEGMENT $segmentName")) + $result | Should -Match ([regex]::Escape("40% of Custom1 attribute -> A1")) + $result | Should -Match ([regex]::Escape("60% of Custom1 attribute -> B1")) + } + + It "Manage variations" { + $predefId = Invoke-ConfigCat "flag-v2", "var", "cr", "-i", $flagV2PredefId3, "-n", "C3", "-H", "C3hint", "-sv", "c3" + $result = Invoke-ConfigCat "flag-v2", "var", "ls", "-i", $flagV2PredefId3 + $result | Should -Match ([regex]::Escape("$predefId C3 `"C3hint`" c3")) + + Invoke-ConfigCat "flag-v2", "var", "up", "-i", $flagV2PredefId3, "-pvi" , $predefId, "-n", "C32", "-H", "C32hint", "-sv", "c32" + $result = Invoke-ConfigCat "flag-v2", "var", "ls", "-i", $flagV2PredefId3 + $result | Should -Match ([regex]::Escape("$predefId C32 `"C32hint`" c32")) + + Invoke-ConfigCat "flag-v2", "var", "rm", "-i", $flagV2PredefId3, "-pvi", $predefId + $result = Invoke-ConfigCat "flag-v2", "var", "ls", "-i", $flagV2PredefId3 + $result | Should -Not -Match ([regex]::Escape("$predefId C32 `"C32hint`" c32")) + } +} + Describe "Scan Tests" { BeforeAll { $flagIdToScan1 = Invoke-ConfigCat "flag", "create", "-c", $configId, "-n", "Flag-To-Scan", "-k", "flag_to_scan", "-H", "hint", "-t", "boolean"