Skip to content

Commit

Permalink
Feature/input validation (#1)
Browse files Browse the repository at this point in the history
* fix validation schema

* cleanup

* fix task.json validation

* add input validation

* update readme
  • Loading branch information
c4rth authored Nov 28, 2021
1 parent 430d2a5 commit 98eb44b
Show file tree
Hide file tree
Showing 18 changed files with 171 additions and 70 deletions.
6 changes: 5 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ name: Lint, Build & Test

on:
push:
branches: [ main ]
branches:
- main
pull_request:
branches:
- main

jobs:
lintBuildTest:
Expand Down
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Change Log
All notable changes to the "ado-task-viewer" extension will be documented in this file.
## Version 0.1.1
## Version 0.2.0
- Add input validation (except "length")
https://github.com/microsoft/azure-pipelines-tasks/blob/master/docs/taskinputvalidation.md
- Fix task.json validation

## Version 0.1.1
- Add clearer readme
- Update packages

## Version 0.1.0

- Initial release
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# AzDevops task.json viewer

## Features
### View task.json of an Azure DevOps custom extension
### View task.json of an Azure DevOps extension
<br/>

Supported :
- input types
- visibility rules
- input validation
- collapsible groups
- help markdown
- validation : based on https://github.com/microsoft/azure-pipelines-task-lib/blob/master/tasks.schema.json
Expand Down
4 changes: 1 addition & 3 deletions app/components/InputsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@ export const InputsPanel: React.FC<IInputsViewProps> = (props): JSX.Element => {

const handleChangeEvent = (inputName: string, value?: string | boolean | undefined) => {
const newAdoTask = {...adoTask};
console.log("updateInputValue : " + inputName + "=" + value + " ("+typeof value+")");
const adoInput = newAdoTask.adoInputs.get(inputName);
if (adoInput) {
adoInput.value = value;
console.log("updateInputValue done : " + inputName + "=" + value + " ("+typeof value+")");
adoInput.value = value;
updateVisibilities(newAdoTask);
}
setAdoTask(newAdoTask);
Expand Down
2 changes: 1 addition & 1 deletion app/components/inputs/InputBoolean.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Checkbox, ICheckboxProps, ICheckboxStyles, IRenderFunction, ITextFieldStyles } from "@fluentui/react";
import { Checkbox, ICheckboxStyles, ITextFieldStyles } from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
import React from "react";
import { LabelInfo } from "../ui/LabelInfo";
Expand Down
15 changes: 12 additions & 3 deletions app/components/inputs/InputInt.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { IRenderFunction, ITextFieldProps, TextField } from "@fluentui/react";
import React, { useCallback } from "react";
import { TextField } from "@fluentui/react";
import React, { useCallback, useState } from "react";
import { isExpressionValid } from "../../helper/inputExpressionValidation";
import { LabelInfo } from "../ui/LabelInfo";
import { evaluateFieldAsBoolean, evaluateFieldAsInt, TaskInputProps } from "./TaskInput";

export const InputInt: React.FC<TaskInputProps> = (props): JSX.Element => {

const [errorMessage, setErrorMessage] = useState<string>(undefined);
const expression = props.adoInput.validation?.expression;
const expressionMessage = props.adoInput.validation?.message;

const _handleTextFieldChangeEvent = useCallback(
(event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string | undefined) => {
if (props.onChange) {
props.onChange(props.adoInput.name, newValue);
}
if (expression) {
setErrorMessage(isExpressionValid(expression, newValue) ? undefined : expressionMessage);
}
},
[]
);
Expand All @@ -26,6 +34,7 @@ export const InputInt: React.FC<TaskInputProps> = (props): JSX.Element => {
onRenderLabel={_onRenderLabel}
defaultValue={props.adoInput.value?.toString()}
onChange={_handleTextFieldChangeEvent}
maxLength={evaluateFieldAsInt(props.adoInput.properties?.maxLength)} />;
maxLength={evaluateFieldAsInt(props.adoInput.properties?.maxLength)}
errorMessage={errorMessage} />;

};
2 changes: 1 addition & 1 deletion app/components/inputs/InputMultiLine.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IRenderFunction, ITextFieldProps, TextField } from "@fluentui/react";
import { TextField } from "@fluentui/react";
import React, { useCallback } from "react";
import { LabelInfo } from "../ui/LabelInfo";
import { evaluateFieldAsBoolean, evaluateFieldAsInt, TaskInputProps } from "./TaskInput";
Expand Down
14 changes: 11 additions & 3 deletions app/components/inputs/InputPickList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ComboBox, Dropdown, IComboBox, IComboBoxOption, IDropdownOption } from "@fluentui/react";
import React from "react";
import React, { useState } from "react";
import { DataSourceBinding, Options } from "../../../src/models/AzureDevOpsTask";
import { isExpressionValid } from "../../helper/inputExpressionValidation";
import { LabelInfo } from "../ui/LabelInfo";
import { evaluateFieldAsStringArray, evaluateFieldAsBoolean, TaskInputProps } from "./TaskInput";

Expand Down Expand Up @@ -49,7 +50,7 @@ export const InputPickList: React.FC<PickListProps> = (props): JSX.Element => {
}
return options;
};

const [options, setOptions] = React.useState(_initOptions(props.type, props.adoInput.value, props.adoInput.options, props.adoInput.dataSourceBinding));
const [selectedKey, setSelectedKey] = React.useState<string | number>(props.adoInput.value?.toString());

Expand All @@ -61,6 +62,10 @@ export const InputPickList: React.FC<PickListProps> = (props): JSX.Element => {
},
[]);

const [errorMessage, setErrorMessage] = useState<string>(undefined);
const expression = props.adoInput.validation?.expression;
const expressionMessage = props.adoInput.validation?.message;

const _handleComboboxChangeEvent = React.useCallback(
(event: React.FormEvent<IComboBox>, option?: IComboBoxOption | undefined, index?: number | undefined, value?: string | undefined) => {
let selected = option?.selected;
Expand All @@ -69,10 +74,12 @@ export const InputPickList: React.FC<PickListProps> = (props): JSX.Element => {
option = { key: value, text: value };
setOptions(prevOptions => [...prevOptions, option!]);
}

if (option) {
setSelectedKey(option.key);
}
if (expression) {
setErrorMessage(isExpressionValid(expression, value ?? option?.key.toString()) ? undefined : expressionMessage);
}
if (props.onChange) {
props.onChange(props.adoInput.name, value ?? option?.key.toString() ?? "");
}
Expand All @@ -97,6 +104,7 @@ export const InputPickList: React.FC<PickListProps> = (props): JSX.Element => {
options={options}
selectedKey={selectedKey}
onChange={_handleComboboxChangeEvent}
errorMessage={errorMessage}
useComboBoxAsMenuWidth />
</>;
} else {
Expand Down
14 changes: 11 additions & 3 deletions app/components/inputs/InputString.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { IRenderFunction, ITextFieldProps, TextField } from "@fluentui/react";
import React, { useCallback } from "react";
import { TextField } from "@fluentui/react";
import React, { useCallback, useState } from "react";
import { LabelInfo } from "../ui/LabelInfo";
import { evaluateFieldAsBoolean, evaluateFieldAsInt, TaskInputProps } from "./TaskInput";
import { isExpressionValid } from "../../helper/inputExpressionValidation";

export const InputString: React.FC<TaskInputProps> = (props): JSX.Element => {
const [errorMessage, setErrorMessage] = useState<string>(undefined);
const expression = props.adoInput.validation?.expression;
const expressionMessage = props.adoInput.validation?.message;

const _handleTextFieldChangeEvent = useCallback(
(event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string | undefined) => {
if (props.onChange) {
props.onChange(props.adoInput.name, newValue);
}
if (expression) {
setErrorMessage(isExpressionValid(expression, newValue) ? undefined : expressionMessage);
}
},
[]
);
Expand All @@ -25,5 +32,6 @@ export const InputString: React.FC<TaskInputProps> = (props): JSX.Element => {
onRenderLabel={_onRenderLabel}
defaultValue={props.adoInput.value?.toString()}
onChange={_handleTextFieldChangeEvent}
maxLength={evaluateFieldAsInt(props.adoInput.properties?.maxLength)} />;
maxLength={evaluateFieldAsInt(props.adoInput.properties?.maxLength)}
errorMessage={errorMessage} />;
};
4 changes: 2 additions & 2 deletions app/components/inputs/TaskInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ export const evaluateFieldAsBoolean = (value: string | boolean | number | undefi
return value.toString().toLowerCase() === "true";
};

export const evaluateFieldAsInt = (value: string | undefined, defaultValue: number | undefined = undefined): number | undefined => {
export const evaluateFieldAsInt = (value: string | number | undefined, defaultValue: number | undefined = undefined): number | undefined => {
if (value === undefined) {
return defaultValue;
}
return parseInt(value);
return parseInt(value.toString());
};
2 changes: 0 additions & 2 deletions app/components/models/AdoTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,11 @@ export function updateVisibilities(adoTask: AdoTask) {
[...adoTask.adoGroups.values()].map((adoGroup) => {
if (adoGroup.visibilityRule) {
adoGroup.isVisible = VisibilityHelper.evaluateVisibility(adoGroup.visibilityRule, adoTask.adoInputs);
//console.log("evaluate group '" + adoGroup.name + "': [" + adoGroup.visibleRule + "] --> " + adoGroup.isVisible);
}
if (adoGroup.isVisible) {
[...adoGroup.adoInputs.values()].map((adoInput) => {
if (adoInput.visibilityRule) {
adoInput.isVisible = VisibilityHelper.evaluateVisibility(adoInput.visibilityRule, adoTask.adoInputs);
//console.log("evaluate input '" + adoInput.name + "': [" + adoInput.visibleRule + "] --> " + adoInput.isVisible);
}
});
}
Expand Down
2 changes: 1 addition & 1 deletion app/components/ui/CollapsiblePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getColorFromString, Icon, IStackStyles, ITextFieldStyles, Label, Stack } from "@fluentui/react";
import { Icon, IStackStyles, ITextFieldStyles, Label, Stack } from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
import React from "react";
import { Collapse } from "react-collapse";
Expand Down
73 changes: 73 additions & 0 deletions app/helper/inputExpressionValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
function isUrl(value: string): boolean {
const regexp = /(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/;
return regexp.test(value);
}

function isIpV4Address(value: string): boolean {
const regexp = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/;
return regexp.test(value);
}

function isEmail(value: string): boolean {
const regexp = /^([\w-.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([\w-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)$/;
return regexp.test(value);
}

function isInRange(expression: string, value: string): boolean {
const expr = expression.substring(9).slice(1, -1);
const splitted = expr.split(",");
const min = Number(splitted[1]);
const max = Number(splitted[2]);
const val = Number(value);
return (min <= val) && (val <= max);
}

function isSha1(value: string): boolean {
const regexp = /[a-fA-F0-9]{40}/;
return regexp.test(value);
}

function isMatch(expression: string, value: string): boolean {
const expr = expression.substring(7).slice(1, -1);
const splitted = expr.match(/('.*?'|[^',\s]+)(?=\s*,|\s*$)/g);
if (splitted === null || splitted.length < 3) {
return false;
}
const rawRegex = splitted[1].slice(1, -1);
const rawOptions = splitted[2].slice(1, -1).toLowerCase();
const options = (rawOptions.includes("ignorecase") ? "i" : "") + (rawOptions.includes("multiline") ? "m" : "");

const regex = new RegExp(rawRegex, options);
return regex.test(value);
}

// https://github.com/Microsoft/azure-pipelines-tasks/blob/master/docs/taskinputvalidation.md
export function isExpressionValid(expression: string | undefined, value: string | undefined): boolean {
if (!expression || expression.length === 0) {
return true;
}
let result = false;
try {
if (expression.startsWith("isMatch(")) {
result = (value) ? isMatch(expression, value) : false;
} else if (expression.startsWith("isInRange(")) {
result = (value) ? isInRange(expression, value) : false;
} else if (expression.startsWith("isUrl(")) {
result = (value) ? isUrl(value) : false;
} else if (expression.startsWith("isIpV4Address(")) {
result = (value) ? isIpV4Address(value) : false;
} else if (expression.startsWith("isEmail(")) {
result = (value) ? isEmail(value) : false;
} else if (expression.startsWith("isSha1(")) {
result = (value) ? isSha1(value) : false;
} else if (expression.startsWith("length(")) {
result = true; // TODO
} else {
result = false;
}
} catch (ex) {
console.error(ex);
return false;
}
return result;
}
34 changes: 17 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 98eb44b

Please sign in to comment.