diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b55c96f..75bdaaf 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -51,7 +51,7 @@ jobs: composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: Execute tests - run: vendor/bin/phpunit --coverage-clover coverage.xml + run: vendor/bin/pest --coverage-clover coverage.xml - name: Upload coverage uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore index 8e3e4c8..94eeaba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor/ +/build/ node_modules/ npm-debug.log yarn-error.log @@ -21,6 +22,7 @@ Homestead.yaml Homestead.json /.vagrant .phpunit.result.cache +.php-cs-fixer.cache .php_cs .php_cs.cache composer.lock diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache deleted file mode 100644 index a910d0b..0000000 --- a/.php-cs-fixer.cache +++ /dev/null @@ -1 +0,0 @@ -{"php":"8.3.0","version":"3.41.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":true,"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_parentheses":true,"no_blank_lines_after_class_opening":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":true,"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true,"class_attributes_separation":{"elements":{"method":"one"}}},"hashes":{"src\/UiServiceProvider.php":"bf523875e640350bdee1ba48a9a1ef73","src\/Facades\/Ui.php":"2df93407364522584dd0449ed7eab8d1","src\/Components\/BaseComponent.php":"fdee75487fab03cdaa3c5f1d6e072567","src\/Ui.php":"ec96cb32a9fcac6082b91081b167a4e2","tests\/TestCase.php":"a0f6a56dd991f0c35941339d925c3ebe","tests\/Models\/Article.php":"16eb5ddaef3df65ab81b33a8e414f143","tests\/database\/migrations\/2021_01_01_000000_create_articles_table.php":"e05c96b26f34720cb56eb19da0b1f25b","tests\/database\/factories\/ArticlesFactory.php":"f1036385fe859c250832fdf00900dac8","tests\/ExampleTest.php":"68041e6f969ee73c9abfbfa075b774a8"}} \ No newline at end of file diff --git a/.php_cs.cache b/.php_cs.cache deleted file mode 100644 index da3a431..0000000 --- a/.php_cs.cache +++ /dev/null @@ -1 +0,0 @@ -{"php":"7.4.13","version":"2.17.3","indent":" ","lineEnding":"\n","rules":{"blank_line_after_namespace":true,"braces":true,"class_definition":true,"constant_case":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_spaces_after_function_name":true,"no_spaces_inside_parenthesis":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_import_per_statement":true,"single_line_after_imports":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"visibility_required":{"elements":["const","method","property"]},"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"blank_line_after_opening_tag":true,"compact_nullable_typehint":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_braces":true,"no_blank_lines_after_class_opening":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_blank_line_before_namespace":true,"single_trait_insert_per_statement":true,"ternary_operator_spaces":true},"hashes":{"tests\/database\/migrations\/2021_01_01_000000_create_articles_table.php":1906224168}} \ No newline at end of file diff --git a/composer.json b/composer.json index e7a665e..7e373a0 100644 --- a/composer.json +++ b/composer.json @@ -23,16 +23,17 @@ "require-dev": { "laravel/legacy-factories": "^1.1", "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "pestphp/pest": "^1.23" }, "autoload": { "psr-4": { - "AppKit\\Ui\\": "src" + "AppKit\\UI\\": "src" } }, "autoload-dev": { "psr-4": { - "AppKit\\Ui\\Tests\\": "tests" + "AppKit\\UI\\Tests\\": "tests" } }, "scripts": { @@ -48,5 +49,10 @@ "Ui": "AppKit\\Ui\\Facades\\Ui" } } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } } } diff --git a/src/AttributeBuilder.php b/src/AttributeBuilder.php new file mode 100644 index 0000000..c96d792 --- /dev/null +++ b/src/AttributeBuilder.php @@ -0,0 +1,471 @@ + + */ + protected $states = []; + + /** + * An array of attribute bags for each of the registered elements + * @var array + */ + protected $elementAttributeBags = []; + + public function __construct( + protected ComponentAttributeBag &$attributeBag, + array $elements = [] + ) { + // loop through each of the elements that have been specified + foreach ($elements as $element) { + // create a new attribute bag for that element + $this->elementAttributeBags[$element] = new ComponentAttributeBag(); + + // generate the element prefix + $elementPrefix = Str::of($element . ':')->kebab()->__toString(); + + // loop through all of the attributes that we have in the attribute bag + foreach ($attributeBag->getAttributes() as $attributeName => $value) { + // get an instance of a String for the attribute name + $attributeString = Str::of($attributeName); + + // check if the attribute name starts with the prefix + if ($attributeString->startsWith($elementPrefix)) { + // get the new name of the attribute, without the prefix + $newAttributeName = $attributeString->remove($elementPrefix)->__toString(); + + // add the attribute to the appropriate element attribute bag + $this->getAttributeBag($element)->offsetSet($newAttributeName, $value); + + // and remove it from the default attribute bag (which will still have the old name) + $this->getAttributeBag()->offsetUnset($attributeName); + } + } + } + } + + /** + * Register a new attribute helper + * + * @param string $name + * @param callable $callback + * @return void + */ + public static function registerAttributeHelper(string $name, callable $callback): void + { + self::$attributeHelpers[$name] = $callback; + } + + /** + * Add classes to the attribute bag + * + * @param mixed $classes + * @return AttributeBuilder + * @throws InvalidArgumentException + */ + public function addClass(...$classes): AttributeBuilder + { + // flatten the arguments into the function + $classes = Arr::flatten($classes); + + // merge the new classes with the existing ones + $this->mergeAttributes(['class' => implode(' ', $classes)]); + + // return a fluent API + return $this; + } + + /** + * Remove classes from the attribute bag + * + * @param mixed $classes + * @return AttributeBuilder + */ + public function removeClass(...$classes): AttributeBuilder + { + // flatten the arguments to the function + $classes = Arr::flatten($classes); + + // calculate the classes that we need to remove + $classesToRemove = collect($classes) + ->map(function ($item) { + // covert every item into an array + if (is_array($item)) { + // if we are already an array, we can just return it + return $item; + } + + // otherwise, we need to split the string on spaces + return explode(' ', $item); + }) + ->flatten() + ->map(function ($item) { + // trim all of the items in the collection + return trim($item); + }) + ->toArray(); + + // get all of the current classes already applied + $currentClasses = explode(' ', $this->getAttribute('class')); + + // create an array to store all of the new classes + $newClasses = []; + + // loop through all of the classes that we already have + foreach ($currentClasses as $currentClass) { + // trim the class + $currentClass = trim($currentClass); + + // check if it's in the list of classes to remove + if (!in_array($currentClass, $classesToRemove)) { + // if it's not, we add it to the list of classes that the attribute bag should have + $newClasses[] = $currentClass; + } + } + + // set the class attribute + $this->setAttribute('class', implode(' ', $newClasses)); + + // return a fluent API + return $this; + } + + /** + * Set an attribute on the attribute bag + * + * @param mixed $attribute + * @param mixed $value + * @param mixed $condition + * @param bool $negateCondition + * @return AttributeBuilder + * @throws RuntimeException + */ + public function setAttribute($attribute, $value = null, $attributeType = null, $condition = null, $negateCondition = false, $element = null, $state = null): AttributeBuilder + { + // if we have a conditional, we need to check if it passes + if ($condition != null && !$this->conditionalPasses($condition, $negateCondition)) { + return $this; + } + + // if we have passed in and array + if (is_array($value)) { + // and have a state element + if ($state) { + // get the value of the state + $stateValue = $this->states[$state](); + + // we need to get the value from the array + $value = array_key_exists($stateValue, $value) ? $value[$stateValue] : null; + } + } + + // loop through each of the attributes that we need to remove + foreach ($this->formatAttributes([$attribute => $value], $attributeType) as $attribute => $value) { + $this->getAttributeBag($element)->offsetSet($attribute, $value); + } + + // return a fluent API + return $this; + } + + /** + * Remove an attribute from the attribute bag + * + * @param mixed $attribute + * @param mixed $attributeType + * @param mixed $condition + * @param bool $negateCondition + * @return AttributeBuilder + * @throws RuntimeException + */ + public function removeAttribute($attribute, $attributeType = null, $condition = null, $negateCondition = false, $element = null): AttributeBuilder + { + // if we have a conditional, we need to check if it passes + if ($condition != null && !$this->conditionalPasses($condition, $negateCondition)) { + return $this; + } + + // make sure that the attributes are an array + $attribute = (array) $attribute; + + // and then fill them with null + $attribute = array_fill_keys($attribute, null); + + // loop through each of the attributes that we need to remove + foreach ($this->formatAttributes($attribute, $attributeType) as $attribute => $value) { + // and remove it + $this->getAttributeBag($element)->offsetUnset($attribute); + } + + // return a fluent API + return $this; + } + + /** + * Get an attribute from the attribute bag + * + * @param mixed $attribute + * @param mixed $default + * @return mixed + */ + public function getAttribute($attribute, $default = null): mixed + { + // get the attribute from the attribute bag + return $this->get($attribute, $default); + } + + /** + * Merge the attributes into the attribute bag + * + * @param mixed $attributes + * @return AttributeBuilder + * @throws InvalidArgumentException + */ + public function mergeAttributes($attributes): AttributeBuilder + { + // merge on the attribute bag will return a new instance, so we need to update our reference to be the new one + $this->attributeBag = $this->attributeBag->merge($attributes); + + // return a fluent API + return $this; + } + + /** + * Format attributes, optionally passing them through an attribute helper + * + * @param array $attributes + * @param string $attributeType + * @return array + */ + protected function formatAttributes($attributes, $attributeType = null): array + { + // ensure that the attributes are an array (it's possible only one has been passed) + $attributes = (array) $attributes; + + // if we don't have an attribute type + if ($attributeType == null) { + // then we just return the attributes as they are + return $attributes; + } elseif (!array_key_exists($attributeType, self::$attributeHelpers)) { + // if we don't have a matching helper, then throw an exception + throw new RuntimeException('No such attribute helper ' . $attributeType); + } + + // an array to store the formatted attributes + $formattedAttributes = []; + + // loop through all of the attributes + foreach ($attributes as $attribute => $value) { + // if we do, we need to pass it through the callback + $attributeHelperResults = self::$attributeHelpers[$attributeType]($attribute, $value); + + // merge in the result of the helper, it's possible the helper sets multiple attributes + $formattedAttributes = $formattedAttributes + $attributeHelperResults; + } + + // return the formatted attributes + return $formattedAttributes; + } + + /** + * Check that a conditional helper passes + * + * @param string|callable $condition + * @param bool $negateCondition + * @return bool + */ + protected function conditionalPasses(string|callable $condition, bool $negateCondition): bool + { + // check if we have a condition that exists in the helpers + if (is_string($condition)) { + $condition = $this->states[$condition]; + } + + // evaluate the conditional, ensuring that it's a boolean + $conditionResult = $condition() === true; + + // negate the result of the conditional if we need to + if ($negateCondition) { + $conditionResult = !$conditionResult; + } + + return $conditionResult; + } + + /** + * Register a state + * + * @param string $name + * @param callable $callable + * @return AttributeBuilder + */ + public function registerState(string $name, callable $callable): AttributeBuilder + { + $this->states[$name] = $callable; + + return $this; + } + + /** + * Return the underlying component attribute bag instance + * + * @return ComponentAttributeBag + */ + public function getAttributeBag(?string $element = null): ComponentAttributeBag + { + if ($element) { + return $this->elementAttributeBags[$element]; + } + + return $this->attributeBag; + } + + private function generateMagicMethodRegexCapture(string $captureGroup, array $values, array $triggers = []) + { + // we need to build up the regex that we are going to use to parse the method + $regexString = ''; + $triggerString = ''; + + // check if we have any attribute helpers + if (!empty($values)) { + // create an array to store all of the helper names + $regexValues = []; + + foreach (array_keys($values) as $value) { + // pull out the name of the helper in studly case + $regexValues[] = Str::of($value)->studly()->__toString(); + } + + if (!empty($triggers)) { + // create an array to store all of the helper names + $triggerValues = []; + + foreach ($triggers as $trigger) { + // pull out the name of the helper in studly case + $triggerValues[] = Str::of($trigger)->studly()->__toString(); + } + + $triggerString = '(' . implode('|', $triggerValues) . ')'; + } + + // create the string that will be included in the regex + $regexString = '(' . $triggerString . '(?P<' . $captureGroup . '>' . implode('|', $regexValues) . '))?'; + } + + return $regexString; + } + + /** + * Magic method to catch everything that we aren't already dealing with + * + * @param mixed $method + * @param mixed $parameters + * @return mixed + * @throws BadMethodCallException + */ + public function __call($method, $parameters) + { + // create the regex + $magicMethodRegex = ' + / + (?Pset|add|remove|toggle) + ' . $this->generateMagicMethodRegexCapture('attributeType', self::$attributeHelpers) . ' + (?P[A-Za-z0-9]*)? + (?PAttribute|Class) + ' . $this->generateMagicMethodRegexCapture('element', $this->elementAttributeBags, ['to', 'on', 'from']) . ' + ' . $this->generateMagicMethodRegexCapture('state', $this->states, ['for']) . ' + ( + (If|When) + (?PNot)? + (?P[A-Za-z0-9]*) + )? + /x + '; + + // an array to store any possible matches from the magic method regex + $magicMethodRegexMatches = []; + + if (preg_match($magicMethodRegex, $method, $magicMethodRegexMatches)) { + $magicMethodParameterNames = ['attribute', 'value', 'attributeType', 'condition', 'negateCondition', 'element']; + + // we alias the add operation to set + if ($magicMethodRegexMatches['operation'] == 'add') { + $magicMethodRegexMatches['operation'] = 'set'; + } + + // calculate the name of the method that we actually want to call + $methodName = $magicMethodRegexMatches['operation'] . $magicMethodRegexMatches['type']; + + // the methods that we are allowed to call via the magic method + $allowedMethods = [ + 'setAttribute', + 'removeAttribute', + ]; + + // check that it is an allowed method + if (in_array($methodName, $allowedMethods)) { + // get the reflection of the method that we are ultimately going to call + $methodReflection = new ReflectionMethod($this, $methodName); + + // get the parameters of that method + $methodParametersReflection = $methodReflection->getParameters(); + + // create an array to store the parameters that we will actually pass to the method + $methodParameterValues = []; + + foreach ($methodParametersReflection as $callableParameter) { + // get the name of the parameter + $parameterName = $callableParameter->getName(); + + // check if we have a matching parameter in the regex matches + if (array_key_exists($parameterName, $magicMethodRegexMatches) && !empty($magicMethodRegexMatches[$parameterName])) { + // if we do, we get that value and set it on the array that will be passed to the method + $methodParameterValues[$parameterName] = lcfirst($magicMethodRegexMatches[$parameterName]); + + // we then remove the parameter name from the list that we are still looking for + $magicMethodParameterNames = array_diff($magicMethodParameterNames, [$parameterName]); + } + } + + // because we have removed some things from the array, we want to reset the keys + $magicMethodParameterNames = array_values($magicMethodParameterNames); + + // loop through everything that is left + foreach ($magicMethodParameterNames as $parameterPosition => $parameterName) { + // and check if it was passed through as a parameter to the magic method + if (isset($parameters[$parameterPosition])) { + // if it was, then we add it to the list of parameters + $methodParameterValues[$parameterName] = $parameters[$parameterPosition]; + } + } + + // finally, we call the underlying method + return call_user_func_array([$this, $methodName], $methodParameterValues); + } + } + + return $this->forwardCallTo($this->attributeBag, $method, $parameters); + } +} diff --git a/src/Components/BaseComponent.php b/src/Components/BaseComponent.php index 5daf914..e02381d 100644 --- a/src/Components/BaseComponent.php +++ b/src/Components/BaseComponent.php @@ -1,6 +1,6 @@ > + */ + protected static $attributeBuilderParsers = []; + + /** + * The elements that have been specified for the attribute builder + * @var array + */ + protected $attributeBuilderElements = []; + + /** + * The instance of the Attribute Builder + * @var AttributeBuilder + */ + protected AttributeBuilder $attributeBuilder; + + protected $attributeBuilderState = []; + + /** + * Add a new attribute builder parser at a given weight + * + * @param Closure $closure + * @param int $weight + * @return void + */ + public static function registerAttributeBuilderParser(Closure $closure, $weight = 10) + { + // if this is the first time that we are seeing the weight + if (!array_key_exists($weight, static::$attributeBuilderParsers)) { + // set up an array to store all of the closures of this weight + static::$attributeBuilderParsers[$weight] = []; + } + + // store the closure in the parses array + static::$attributeBuilderParsers[$weight][] = $closure; + } + + /** + * @see registerAttributeBuilderParser + */ + public static function customize(Closure $closure, $weight = 10) + { + // this is just an alias to registerAttributeBuilderParser + static::registerAttributeBuilderParser(...func_get_args()); + } + + /** + * @see registerAttributeBuilderParser + */ + public static function customise(Closure $closure, $weight = 10) + { + // this is just an alias to registerAttributeBuilderParser + static::registerAttributeBuilderParser(...func_get_args()); + } + + /** + * Reset all of the attribute builder parsers + * + * @return void + */ + public static function resetAllAttributeBuilderParsers() + { + // empty out the array + static::$attributeBuilderParsers = []; + } + + /** + * @see resetAllAttributeBuilderParsers + */ + public static function resetAllCustomisations() + { + // this is just an alias to resetAllAttributeBuilderParsers + static::resetAllAttributeBuilderParsers(); + } + + /** + * @see resetAllAttributeBuilderParsers + */ + public static function resetAllCustomizations() + { + // this is just an alias to resetAllAttributeBuilderParsers + static::resetAllAttributeBuilderParsers(); + } + + public function exposePropertyAsState($property, $state = null) + { + if (!$state) { + $state = $property; + } + + $this->attributeBuilderState[$state] = fn () => $this->{$property}; + } + + /** + * Register a new element for the attribute builder + * + * @param string $element + * @return ElementAttributeBagWrapper + */ + protected function registerAttributeBuilderElement(string $element): ElementAttributeBagWrapper + { + // add the name to the array of elements + $this->attributeBuilderElements[] = $element; + + // return a wrapper, as we will need to generate the actual content attributes later + return new ElementAttributeBagWrapper($element); + } + + /** + * Run the attribute builder, and return the data that gets passed to the renderer + * + * @param array $data + * @return array + */ + protected function runAttributeBuilder(array $data): array + { + // get the instance of the attribute builder + $this->attributeBuilder = new AttributeBuilder($data['attributes'], $this->attributeBuilderElements); + + foreach ($this->attributeBuilderState as $state => $closure) { + $this->attributeBuilder->registerState($state, $closure); + } + + // sort the parsers by their weight + ksort(static::$attributeBuilderParsers); + + // loop through each of the weights + foreach (static::$attributeBuilderParsers as $parsers) { + // and then through each of the parser of that weight + foreach ($parsers as $parser) { + // run the parser + $parser($this->attributeBuilder, $this); + } + } + + // pull out the "new" attributes + $data['attributes'] = $this->attributeBuilder->getAttributeBag(); + + // loop through each piece of data that we have + foreach ($data as $dataName => $dataElement) { + // check if it it's an instance of an element attribute bag + if ($dataElement instanceof ElementAttributeBagWrapper) { + // if it is, pull out the attributes and set everything we need to + $this->{$dataName} = $data[$dataName] = $dataElement->run($this->attributeBuilder); + } + } + + // return the updated data + return $data; + } + + /** + * Get the underlying attribute builder instance + * + * @return AttributeBuilder + */ + public function getAttributeBuilder(): AttributeBuilder + { + return $this->attributeBuilder; + } +} diff --git a/src/ElementAttributeBagWrapper.php b/src/ElementAttributeBagWrapper.php new file mode 100644 index 0000000..8fe0fca --- /dev/null +++ b/src/ElementAttributeBagWrapper.php @@ -0,0 +1,16 @@ +getAttributeBag($this->element); + } +} diff --git a/src/Facades/Ui.php b/src/Facades/Ui.php index e5ef800..67b9f8a 100644 --- a/src/Facades/Ui.php +++ b/src/Facades/Ui.php @@ -1,11 +1,11 @@ setAttribute('foo', 'bar', 'data'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('data-foo', 'bar'); +}); + +it('can use attribute helpers via the magic method', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // add in the data helper + addDataAttributeHelperToAttributeBuilder(); + + // add the class to the attribute builder + $attributeBuilder->setDataAttribute('foo', 'bar'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('data-foo', 'bar'); +}); + +it('can use attribute helpers and attribute name via the magic method', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // add in the data helper + addDataAttributeHelperToAttributeBuilder(); + + // add the class to the attribute builder + $attributeBuilder->setDataFooAttribute('bar'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('data-foo', 'bar'); +}); + +it('can have attribute helpers which modify the attributes being removed', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(['data-foo' => 'bar']); + + // add in the data helper + addDataAttributeHelperToAttributeBuilder(); + + // add the class to the attribute builder + $attributeBuilder->removeAttribute('foo', 'data'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->not()->toHaveKey('data-foo'); +}); + +it('can use attribute helpers to remove via the magic method', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(['data-foo' => 'bar']); + + // add in the data helper + addDataAttributeHelperToAttributeBuilder(); + + // add the class to the attribute builder + $attributeBuilder->removeDataAttribute('foo'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->not()->toHaveKey('data-foo'); +}); + +it('can use attribute helpers and attribute name to remove via the magic method', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(['data-foo' => 'bar']); + + // add in the data helper + addDataAttributeHelperToAttributeBuilder(); + + // add the class to the attribute builder + $attributeBuilder->removeDataFooAttribute(); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->not()->toHaveKey('data-foo'); +}); diff --git a/tests/AttributeBagAttributeTest.php b/tests/AttributeBagAttributeTest.php new file mode 100644 index 0000000..c8d4299 --- /dev/null +++ b/tests/AttributeBagAttributeTest.php @@ -0,0 +1,67 @@ +setAttribute('foo', 'bar'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('foo', 'bar'); +}); + +it('can change the value of an existing attribute', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(['foo' => 'bar']); + + // add the class to the attribute builder + $attributeBuilder->setAttribute('foo', 'bat'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('foo', 'bat'); +}); + +it('can remove an existing attribute', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(['foo' => 'bar']); + + // add the class to the attribute builder + $attributeBuilder->removeAttribute('foo'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->not()->toHaveKey('foo'); +}); + +it('can remove multiple existing attributes via an array', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(['foo' => 'bar', 'bat' => 'buz']); + + // add the class to the attribute builder + $attributeBuilder->removeAttribute(['foo', 'bat']); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->not()->toHaveKeys(['foo', 'bat']); +}); + +it('can add an attribute via a magic method', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // add the class to the attribute builder + $attributeBuilder->setFooAttribute('bar'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('foo', 'bar'); +}); + +it('can remove an attribute via a magic method', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(['foo' => 'bar']); + + // add the class to the attribute builder + $attributeBuilder->removeFooAttribute(); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->not()->toHaveKey('foo'); +}); diff --git a/tests/AttributeBagClassTest.php b/tests/AttributeBagClassTest.php new file mode 100644 index 0000000..9411a36 --- /dev/null +++ b/tests/AttributeBagClassTest.php @@ -0,0 +1,49 @@ +getAttributes())->toBeEmpty(); +}); + +it('can add classes in multiple ways', function (string $method, $data) { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // add the class to the attribute builder + $attributeBuilder->addClass($data); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('class', 'class-1 class-2 class-3 class-4 class-5'); +})->with($classData); + +it('can add classes to the classes that already exist', function (string $method, $data) { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(['class' => 'class-6']); + + // add the class to the attribute builder + $attributeBuilder->addClass($data); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('class', 'class-1 class-2 class-3 class-4 class-5 class-6'); +})->with($classData); + +it('can remove classes in multiple ways', function (string $method, $data) { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(['class' => 'class-1 class-2 class-3 class-4 class-5 class-6']); + + // add the class to the attribute builder + $attributeBuilder->removeClass($data); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('class', 'class-6'); +})->with($classData); diff --git a/tests/AttributeBagConditionalsTest.php b/tests/AttributeBagConditionalsTest.php new file mode 100644 index 0000000..8031e48 --- /dev/null +++ b/tests/AttributeBagConditionalsTest.php @@ -0,0 +1,103 @@ +setAttribute('foo', 'bar', condition: fn () => true) + ->setAttribute('bat', 'ball', condition: fn () => false); + + // check that the classes are correct + expect($attributeBuilder->getAttributes()) + ->toHaveKey('foo', 'bar') + ->not()->toHaveKey('bat', 'ball'); +}); + +it('can have conditionals specified as helpers', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // add the conditional helpers + addStatesToAttributeBuilder($attributeBuilder); + + // add the class to the attribute builder + $attributeBuilder + ->setAttribute('foo', 'bar', condition: 'true') + ->setAttribute('bat', 'ball', condition: 'false'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes()) + ->toHaveKey('foo', 'bar') + ->not()->toHaveKey('bat', 'ball'); +}); + +it('can use conditionals in magic methods', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // add the conditional helpers + addStatesToAttributeBuilder($attributeBuilder); + + // add the class to the attribute builder + $attributeBuilder + ->setAttributeIfTrue('foo', 'bar') + ->setAttributeIfFalse('bat', 'ball'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes()) + ->toHaveKey('foo', 'bar') + ->not()->toHaveKey('bat', 'ball'); +}); + +it('can specify a negated condition to apply an attribute', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // add the class to the attribute builder + $attributeBuilder + ->setAttribute('foo', 'bar', condition: fn () => false, negateCondition: true) + ->setAttribute('bat', 'ball', condition: fn () => true, negateCondition: true); + + // check that the classes are correct + expect($attributeBuilder->getAttributes()) + ->toHaveKey('foo', 'bar') + ->not()->toHaveKey('bat', 'ball'); +}); + +it('can have negated conditionals specified as helpers', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // add the conditional helpers + addStatesToAttributeBuilder($attributeBuilder); + + // add the class to the attribute builder + $attributeBuilder + ->setAttribute('foo', 'bar', condition: 'false', negateCondition: true) + ->setAttribute('bat', 'ball', condition: 'true', negateCondition: true); + + // check that the classes are correct + expect($attributeBuilder->getAttributes()) + ->toHaveKey('foo', 'bar') + ->not()->toHaveKey('bat', 'ball'); +}); + +it('can use negated conditionals in magic methods', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // add the conditional helpers + addStatesToAttributeBuilder($attributeBuilder); + + // add the class to the attribute builder + $attributeBuilder + ->setAttributeIfNotFalse('foo', 'bar') + ->setAttributeIfNotTrue('bat', 'ball'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes()) + ->toHaveKey('foo', 'bar') + ->not()->toHaveKey('bat', 'ball'); +}); diff --git a/tests/AttributeBagStateBasedAttributeTest.php b/tests/AttributeBagStateBasedAttributeTest.php new file mode 100644 index 0000000..ed8dbf8 --- /dev/null +++ b/tests/AttributeBagStateBasedAttributeTest.php @@ -0,0 +1,43 @@ + 'small', + 'md' => 'medium', + 'lg' => 'large', + ]; + + // add the class to the attribute builder + $attributeBuilder->setAttribute('foo', $values, state: 'size'); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('foo', $values[$size]); +})->with(['sm', 'md', 'lg']); + +it('can define the value of an attribute based on a state value via a magic method', function ($size) { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(); + + // set the size to be small + addSizeStateToAttributeBuilder($attributeBuilder, $size); + + // get the values of the attribute + $values = [ + 'sm' => 'small', + 'md' => 'medium', + 'lg' => 'large', + ]; + + // add the class to the attribute builder + $attributeBuilder->setAttributeForSize('foo', $values); + + // check that the classes are correct + expect($attributeBuilder->getAttributes())->toHaveKey('foo', $values[$size]); +})->with(['sm', 'md', 'lg']); diff --git a/tests/ComponentCustomisationTest.php b/tests/ComponentCustomisationTest.php new file mode 100644 index 0000000..c9b238e --- /dev/null +++ b/tests/ComponentCustomisationTest.php @@ -0,0 +1,111 @@ +setAttribute('foo', 'bar'); + }); + + // render a component + $this->blade(''); + + // get the instance of the component that was rendered + $instance = HigherOrderTestComponent::lastInstance(); + + // check that the attributes are created, and in the correct place + expect($instance->getAttributeBuilder()->getAttributes())->toHaveKey('foo', 'bar'); +}); + +it('can apply multiple customisations', function () { + HigherOrderTestComponent::customise(function (AttributeBuilder $attributes) { + $attributes->setAttribute('foo', 'bar'); + }); + + HigherOrderTestComponent::customise(function (AttributeBuilder $attributes) { + $attributes->setAttribute('bat', 'ball'); + }); + + // render a component + $this->blade(''); + + // get the instance of the component that was rendered + $instance = HigherOrderTestComponent::lastInstance(); + + // check that the attributes are created, and in the correct place + expect($instance->getAttributeBuilder()->getAttributes())->toHaveKey('foo', 'bar'); + expect($instance->getAttributeBuilder()->getAttributes())->toHaveKey('bat', 'ball'); +}); + +it('can apply customisations with weights', function () { + HigherOrderTestComponent::customise(function (AttributeBuilder $attributes) { + $attributes->setAttribute('foo', 'bar'); + }); + + HigherOrderTestComponent::customise(function (AttributeBuilder $attributes) { + $attributes->setAttribute('foo', 'ball'); + }, 20); + + // render a component + $this->blade(''); + + // get the instance of the component that was rendered + $instance = HigherOrderTestComponent::lastInstance(); + + // check that the attributes are created, and in the correct place + expect($instance->getAttributeBuilder()->getAttributes())->toHaveKey('foo', 'ball'); +}); + +it('applies customisations in the order they were defined for equal weights', function () { + HigherOrderTestComponent::customise(function (AttributeBuilder $attributes) { + $attributes->setAttribute('foo', 'bar'); + }); + + HigherOrderTestComponent::customise(function (AttributeBuilder $attributes) { + $attributes->setAttribute('foo', 'bat'); + }); + + // render a component + $this->blade(''); + + // get the instance of the component that was rendered + $instance = HigherOrderTestComponent::lastInstance(); + + // check that the attributes are created, and in the correct place + expect($instance->getAttributeBuilder()->getAttributes())->toHaveKey('foo', 'bat'); +}); + +it('can pass properties down to state', function () { + HigherOrderTestComponent::customise(function (AttributeBuilder $attributes) { + $attributes->setAttributeWhenToggle('foo', 'bar'); + }); + + // render a component + $this->blade(''); + + // get the instance of the component that was rendered + $instance = HigherOrderTestComponent::lastInstance(); + + // check that the attributes are created, and in the correct place + expect($instance->getAttributeBuilder()->getAttributes())->not()->toHaveKey('foo', 'bar'); + + // render another component + $this->blade(''); + + // get the instance of the component that was rendered + $instance = HigherOrderTestComponent::lastInstance(); + + // check that the attributes are created, and in the correct place + expect($instance->getAttributeBuilder()->getAttributes())->toHaveKey('foo', 'bar'); +}); diff --git a/tests/ComponentIntegrationTest.php b/tests/ComponentIntegrationTest.php new file mode 100644 index 0000000..d1c733c --- /dev/null +++ b/tests/ComponentIntegrationTest.php @@ -0,0 +1,34 @@ +blade(''); + + // get the instance of the component that was rendered + $instance = HigherOrderTestComponent::lastInstance(); + + // check that we have created an attribute builder, with the correct properties and values + expect($instance)->toHaveProperty('attributeBuilder'); + expect($instance->getAttributeBuilder())->toBeInstanceOf(AttributeBuilder::class); + expect($instance->getAttributeBuilder()->getAttributes())->toHaveKey('foo', 'bar'); +}); + +it('can take in element attributes by prefix', function () { + // render a component + $this->blade(''); + + // get the instance of the component that was rendered + $instance = HigherOrderTestComponent::lastInstance(); + + // check that the attributes are created, and in the correct place + expect($instance->getAttributeBuilder()->getAttributes())->not()->toHaveKey('foo', 'bar'); + expect($instance->getAttributeBuilder()->getAttributeBag('label')->getAttributes())->toHaveKey('foo', 'bar'); +}); diff --git a/tests/Components/HigherOrderTestComponent.php b/tests/Components/HigherOrderTestComponent.php new file mode 100644 index 0000000..6e5a6ea --- /dev/null +++ b/tests/Components/HigherOrderTestComponent.php @@ -0,0 +1,38 @@ +labelAttributes = $this->registerAttributeBuilderElement('label'); + + $this->exposePropertyAsState('toggle'); + } + + /** + * Render the component + * + * @return Closure + */ + public function render() + { + return function ($data) { + $data = $this->runAttributeBuilder($data); + }; + } +} diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index 8c7bea8..0000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,24 +0,0 @@ -assertTrue(true); - } - - /** @test */ - public function articlesCanBeLoaded() - { - // create 5 articles - factory(Article::class, 5)->create(); - - // check the database for 5 articles - $this->assertEquals(5, Article::count()); - } -} diff --git a/tests/Models/Article.php b/tests/Models/Article.php deleted file mode 100644 index 5017400..0000000 --- a/tests/Models/Article.php +++ /dev/null @@ -1,18 +0,0 @@ -setAttribute('foo', 'bar', element: 'label'); + + // check that the attribute is in the label attribute bag + expect($attributeBuilder->getAttributeBag('label')->getAttributes())->toHaveKey('foo', 'bar'); + + // check that the attribute has not been added to the default attribute bag + expect($attributeBuilder->getAttributes())->not()->toHaveKey('foo', 'bar'); +}); + +it('can have element attributes defined when creating the builder', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(attributes: ['label:foo' => 'bar'], elements: ['label']); + + // check that the attribute is in the label attribute bag + expect($attributeBuilder->getAttributeBag('label')->getAttributes())->toHaveKey('foo', 'bar'); + + // check that the attribute has not been added to the default attribute bag + expect($attributeBuilder->getAttributes())->not()->toHaveKey('foo', 'bar'); +}); + +it('can remove attributes from a particular element attribute bag', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(attributes: ['label:foo' => 'bar'], elements: ['label']); + + // remove the attribute from the attribute bag + $attributeBuilder->removeAttribute('foo', element: 'label'); + + // check that the attribute is in the label attribute bag + expect($attributeBuilder->getAttributeBag('label')->getAttributes())->not()->toHaveKey('foo', 'bar'); + + // check that the attribute has not been added to the default attribute bag + expect($attributeBuilder->getAttributes())->not()->toHaveKey('foo', 'bar'); +}); + +it('can use magic methods to add to a different attribute bag', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(elements: ['label']); + + // add the class to the attribute builder + $attributeBuilder->setAttributeOnLabel('foo', 'bar'); + + // check that the attribute is in the label attribute bag + expect($attributeBuilder->getAttributeBag('label')->getAttributes())->toHaveKey('foo', 'bar'); + + // check that the attribute has not been added to the default attribute bag + expect($attributeBuilder->getAttributes())->not()->toHaveKey('foo', 'bar'); +}); + +it('can use magic methods to remove from a different attribute bag', function () { + // create a new attribute builder + $attributeBuilder = createAttributeBuilder(attributes: ['label:foo' => 'bar'], elements: ['label']); + + // add the class to the attribute builder + $attributeBuilder->removeAttributeFromLabel('foo'); + + // check that the attribute is in the label attribute bag + expect($attributeBuilder->getAttributeBag('label')->getAttributes())->not()->toHaveKey('foo', 'bar'); + + // check that the attribute has not been added to the default attribute bag + expect($attributeBuilder->getAttributes())->not()->toHaveKey('foo', 'bar'); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..598b005 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,78 @@ +in('.'); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeOne', function () { + return $this->toBe(1); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ + +/** + * Create an instance of an attribute builder + * + * @param array $attributes + * @param array $options + * @return AttributeBuilder + */ +function createAttributeBuilder($attributes = [], $elements = []) +{ + // create an attribute bag that will be passed to the attribute builder + $attributeBag = new ComponentAttributeBag($attributes); + + // return the attribute builder + return new AttributeBuilder($attributeBag, $elements); +} + +function addDataAttributeHelperToAttributeBuilder() +{ + AttributeBuilder::registerAttributeHelper('data', function ($attribute, $value) { + return ['data-' . $attribute => $value]; + }); +} + +function addStatesToAttributeBuilder(AttributeBuilder $attributeBuilder) +{ + $attributeBuilder->registerState('true', fn () => true); + $attributeBuilder->registerState('false', fn () => false); + +} + +function addSizeStateToAttributeBuilder(AttributeBuilder $attributeBuilder, $size) +{ + $attributeBuilder->registerState('size', fn () => $size); +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 2b7e3db..2398b90 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,13 +1,16 @@ define(Article::class, function (Faker $faker) { - return [ - 'title' => $faker->sentence, - 'body' => $faker->paragraph, - ]; -}); diff --git a/tests/database/migrations/2021_01_01_000000_create_articles_table.php b/tests/database/migrations/2021_01_01_000000_create_articles_table.php deleted file mode 100644 index 3dc7a07..0000000 --- a/tests/database/migrations/2021_01_01_000000_create_articles_table.php +++ /dev/null @@ -1,33 +0,0 @@ -increments('id'); - $table->string('title'); - $table->text('body'); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('articles'); - } -}