diff --git a/.github/coverage.svg b/.github/coverage.svg deleted file mode 100644 index c9314b9..0000000 --- a/.github/coverage.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - coverage - - 77 % - - \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index dc7aaba..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: tests - -on: - pull_request: - branches: - - main - push: - branches: - - main - -jobs: - tests: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: true - matrix: - os: [ ubuntu-latest ] - php: [ 8.4, 8.3, 8.2, 8.1 ] - laravel: [ 13.*, 12.*, 11.*, 10.*, 9.* ] - stability: [ prefer-lowest, prefer-stable ] - include: - - laravel: 13.* - testbench: 11.* - carbon: ^3.8.4 - medialibrary: 11.* - geospatial: ^4.7 - - laravel: 12.* - testbench: 10.* - carbon: ^3.8.4 - medialibrary: 11.* - geospatial: ^4.7 - - laravel: 11.* - testbench: 9.* - carbon: ^2.63 - medialibrary: 11.* - geospatial: ^4.7 - - laravel: 10.* - testbench: 8.* - carbon: ^2.63 - medialibrary: ^10.3.4 - geospatial: ^4.7 - - laravel: 9.* - testbench: 7.* - carbon: ^2.63 - medialibrary: ^10.3.4 - geospatial: ^2.10 - exclude: - - laravel: 13.* - php: 8.2 - - laravel: 13.* - php: 8.1 - - laravel: 12.* - php: 8.1 - - laravel: 11.* - php: 8.1 - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} - - services: - mysql: - image: ${{ (matrix.laravel == '9.*') && 'mysql:8.0' || '' }} - env: - MYSQL_USER: user - MYSQL_PASSWORD: secret - MYSQL_DATABASE: forms - MYSQL_ROOT_PASSWORD: secretroot - ports: - - 3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install SQLite 3 - run: | - sudo apt-get update - sudo apt-get install sqlite3 -y - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv - coverage: ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && 'xdebug' || 'none' }} - - - name: Install dependencies - run: | - composer config --no-plugins allow-plugins.phpstan/extension-installer true - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench-browser-kit:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" "spatie/laravel-medialibrary:${{ matrix.medialibrary }}" "matanyadaev/laravel-eloquent-spatial:${{ matrix.geospatial }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: Execute tests - run: | - vendor/bin/phpunit ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && '--coverage-clover=clover.xml' || '' }} - env: - DB_USERNAME: user - DB_PASSWORD: secret - DB_PORT: ${{ (matrix.laravel == '9.*') && job.services.mysql.ports[3306] || '' }} - - - name: Make code coverage badge - if: startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' - uses: timkrase/phpunit-coverage-badge@v1.2.1 - with: - coverage_badge_path: .github/coverage.svg - push_badge: true - repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..357a04e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,146 @@ +name: tests + +on: + push: + branches: + - main + - next-gen + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + laravel: ['12.*', '13.*'] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: '12.*' + testbench: '10.*' + - laravel: '13.*' + testbench: '11.*' + + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} + + env: + COVERAGE: ${{ matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv, intl + coverage: ${{ env.COVERAGE == 'true' && 'xdebug' || 'none' }} + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --dev --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit ${{ env.COVERAGE == 'true' && '--coverage-clover=clover.xml' || '' }} + + - name: Upload coverage to Codecov + if: env.COVERAGE == 'true' + uses: codecov/codecov-action@v5 + with: + files: clover.xml + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + pint: + runs-on: ubuntu-latest + + name: Pint + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Check code style + run: vendor/bin/pint --test + + - name: No javaabu/settings or javaabu/helpers in production dependencies + run: | + if composer show --tree --no-dev | grep -E 'javaabu/(settings|helpers)'; then + echo "javaabu/forms must not require javaabu/settings or javaabu/helpers (see #18)" + exit 1 + fi + + - name: Boost component reference is up to date + run: | + php scripts/components-reference.php + git diff --exit-code -- resources/boost + + assets: + runs-on: ubuntu-latest + + name: Assets + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Check formatting + run: pnpm format:check + + - name: Type check + run: pnpm typecheck + + - name: Execute tests + run: pnpm test --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: coverage/clover.xml + flags: js + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Onu classes match @javaabu/onu + run: pnpm check + + - name: Built files are up to date + run: | + pnpm icons + pnpm locales + pnpm build + git diff --exit-code -- resources/icons resources/js/locales dist diff --git a/.gitignore b/.gitignore index d42e817..2f7e222 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage .php-cs-fixer.cache clover.xml coverage.xml +node_modules diff --git a/.php_cs.dist.php b/.php_cs.dist.php deleted file mode 100644 index 8648fdc..0000000 --- a/.php_cs.dist.php +++ /dev/null @@ -1,35 +0,0 @@ -in([ - __DIR__ . '/src', - __DIR__ . '/tests', - ]) - ->name('*.php') - ->notName('*.blade.php') - ->ignoreDotFiles(true) - ->ignoreVCS(true); - -return (new PhpCsFixer\Config()) - ->setRules([ - '@PSR12' => true, - 'array_syntax' => ['syntax' => 'short'], - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'trailing_comma_in_multiline' => true, - 'phpdoc_scalar' => true, - 'unary_operator_spaces' => true, - 'binary_operator_spaces' => true, - 'blank_line_before_statement' => [ - 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], - ], - 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_var_without_name' => true, - 'method_argument_space' => [ - 'on_multiline' => 'ensure_fully_multiline', - 'keep_multiple_spaces_after_comma' => true, - ], - 'single_trait_insert_per_statement' => true, - ]) - ->setFinder($finder); diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9b934c0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +dist +coverage +node_modules +vendor +build +pnpm-lock.yaml +resources/js/locales diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6c1772b --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "printWidth": 120, + "tabWidth": 2 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6826a14 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# Forms: notes for AI coding agents + +`javaabu/forms` is a set of Laravel Blade components (``) for forms and admin pages, with model binding, old input and validation errors. v2 renders for two frameworks, `onu` (Tailwind with `@javaabu/onu`'s classes, the default) and `bootstrap-6`, and has a React twin, `@javaabu/forms` (`Javaabu/react-forms`), with the same components and attributes. + +## Layout + +| Path | What | +| --- | --- | +| `src/Views/Components` | One class per component. Constructor parameters are the attributes; public methods and properties reach the view. `Component` is the base: framework, view resolution, ids, labels, icons, shared field options, assets. | +| `src/Frameworks` | `Framework` contract, `Onu` and `Bootstrap6` class maps (colours → classes), `Frameworks` registry | +| `src/Assets/Assets.php` | The only code that knows javaabu/manifest's registration API: the package's JS/CSS assets | +| `src/Support` | Traits for binding, old input, errors, media and formatting; `Settings` defaults; `Icons\Svg` | +| `resources/views/{onu,bootstrap-6}` | Per-framework views; `resources/views/common` is the fallback for both | +| `resources/onu/classes.php` | Generated by `scripts/onu-classes.mjs` from Onu's React components. Never edit by hand. | +| `resources/icons/icons.php` | Generated by `scripts/icons.mjs` from `lucide-static`. Never edit by hand. | +| `resources/js`, `resources/css` | Browser behaviours (TypeScript, no jQuery) and styles, built into `dist/` (committed) | +| `lang/{en,dv}` | Every user-facing string. Keys match `@javaabu/forms`' locales. | +| `docs`, `llms.txt` | Docusaurus pages and the index for AI assistants, both shipped in the package | +| `resources/boost` | Laravel Boost guideline and skill | +| `tests/Feature`, `tests/Unit`, `tests/TestSupport/views` | PHPUnit + BrowserKit tests and fixture views | + +## Rules + +- **Component names and attributes are a public API** used by every next-gen package. Don't rename or remove one; add new attributes with defaults that keep current output. +- **Both frameworks, always.** A markup change goes in `resources/views/onu` and `resources/views/bootstrap-6` (or `common` when the markup has no framework classes). `FrameworkSmokeTest` renders every fixture view with every framework. +- **Onu classes come from Onu.** Use `$frameworkClasses()->classes('slot.key')`; if a class you need is missing, extend `scripts/onu-classes.mjs` to capture it from Onu's own component and run `pnpm onu-classes`. Don't type Onu class strings into views. +- **Parity with React.** New components or attributes must match `@javaabu/forms` (camelCase there). Update `docs/parity.md`, and open a `cross-package` issue in `Javaabu/react-forms` when they differ. +- **Shared field options** (`form-group-class`, `thaana`, `auto-resize`, `decimals`) are read from the attribute bag in `Component::withAttributes()`. Views read them through methods (`$groupClass()`, `$thaanaAttributes()`, …) because Blade captures properties before `withAttributes()` runs. +- **No hard-coded text.** Strings go in `lang/en/strings.php` and `lang/dv/strings.php` (real Dhivehi; list uncertain lines in `docs/advanced-usage/translations-and-rtl.md`), using the same key as `@javaabu/forms` when one exists. +- **RTL:** logical utilities only (`ms-`, `pe-`, `start-`, `text-start`); never `left`/`right`. +- **Assets:** a component declares what it needs by overriding `assets()` (names from `Assets`). Don't push ` diff --git a/resources/views/partials/wysiwyg-script.blade.php b/resources/views/partials/wysiwyg-script.blade.php deleted file mode 100644 index df0c4e9..0000000 --- a/resources/views/partials/wysiwyg-script.blade.php +++ /dev/null @@ -1,21 +0,0 @@ - - diff --git a/scripts/components-reference.php b/scripts/components-reference.php new file mode 100644 index 0000000..f0d8c27 --- /dev/null +++ b/scripts/components-reference.php @@ -0,0 +1,79 @@ +getExtension() !== 'php') { + continue; + } + + $relative = substr($file->getPathname(), strlen($dir) + 1, -4); + $class = 'Javaabu\\Forms\\Views\\Components\\' . str_replace('/', '\\', $relative); + $reflection = new ReflectionClass($class); + + if ($reflection->isAbstract()) { + continue; + } + + $tag = implode('.', array_map(fn ($part) => Str::kebab($part), explode('/', $relative))); + $attributes = []; + + foreach ($reflection->getConstructor()?->getParameters() ?? [] as $parameter) { + if ($parameter->getName() === 'framework') { + continue; + } + + $name = Str::kebab($parameter->getName()); + $type = (string) ($parameter->getType() ?? 'mixed'); + + if (! $parameter->isDefaultValueAvailable()) { + $attributes[] = "`{$name}` ({$type}, required)"; + + continue; + } + + $default = $parameter->getDefaultValue(); + $default = match (true) { + is_bool($default) => $default ? 'true' : 'false', + is_null($default) => 'null', + is_array($default) => $default ? 'array' : '[]', + $default === '' => "''", + default => (string) $default, + }; + + $attributes[] = "`{$name}` = {$default}"; + } + + $rows[$tag] = "| `x-forms::{$tag}` | " . ($attributes ? implode(', ', $attributes) : '—') . ' |'; +} + +ksort($rows); + +$output = <<<'MD' +# Component attributes + +Generated from the component constructors by `scripts/components-reference.php`. Every field also accepts `form-group-class`, `thaana`, `auto-resize` (textarea) and `decimals` (number), every component accepts `framework`, and any other attribute (`class`, `id`, `placeholder`, `min`, `step`, `data-*`, …) goes on the control. + +| Component | Attributes (kebab-case) = default | +| --- | --- | + +MD; + +file_put_contents( + __DIR__ . '/../resources/boost/skills/javaabu-forms/references/components.md', + $output . implode("\n", $rows) . "\n", +); + +echo count($rows) . " components\n"; diff --git a/scripts/icons.mjs b/scripts/icons.mjs new file mode 100644 index 0000000..bea7043 --- /dev/null +++ b/scripts/icons.mjs @@ -0,0 +1,72 @@ +// Copies the lucide icons the components use into resources/icons/icons.php. +// Run with `pnpm icons` after changing the list below. +import { readFileSync, writeFileSync } from "node:fs" +import { createRequire } from "node:module" +import { dirname, join } from "node:path" + +const icons = [ + "arrow-down", + "arrow-up", + "arrow-up-down", + "calendar", + "check", + "chevron-down", + "chevron-left", + "chevron-right", + "chevron-up", + "circle-alert", + "circle-check", + "circle-x", + "clock", + "download", + "ellipsis", + "external-link", + "eye", + "file", + "file-text", + "image", + "info", + "loader-circle", + "map-pin", + "pencil", + "plus", + "search", + "trash-2", + "triangle-alert", + "upload", + "x", +] + +const require = createRequire(import.meta.url) +const root = join(dirname(require.resolve("lucide-static/package.json")), "icons") +const { version } = require("lucide-static/package.json") + +const entries = icons.map((name) => { + const svg = readFileSync(join(root, `${name}.svg`), "utf8") + const body = svg + .slice(svg.indexOf(">", svg.indexOf("")) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .join("") + .replaceAll(" />", "/>") + + return ` '${name}' => '${body.replaceAll("'", "\\'")}',` +}) + +writeFileSync( + "resources/icons/icons.php", + ` entry.isDirectory()) + .map((entry) => entry.name) + .sort() + +for (const locale of locales) { + const file = `${LANG}/${locale}/strings.php` + const json = execFileSync( + "php", + [ + "-r", + "echo json_encode(require $argv[1], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);", + file, + ], + { encoding: "utf8" }, + ) + const strings = JSON.parse(json) + const sorted = Object.fromEntries( + Object.keys(strings) + .sort() + .map((key) => [key, strings[key]]), + ) + + writeFileSync(`${OUT}/${locale}.json`, JSON.stringify(sorted, null, 2) + "\n") + console.log(`${OUT}/${locale}.json: ${Object.keys(sorted).length} strings`) +} diff --git a/scripts/onu-classes.mjs b/scripts/onu-classes.mjs new file mode 100644 index 0000000..b1f57e8 --- /dev/null +++ b/scripts/onu-classes.mjs @@ -0,0 +1,193 @@ +// Extracts the class strings @javaabu/onu renders, so the Blade `onu` views output +// exactly the same classes as the React components. +// +// It server-renders Onu's own components and records the classes of every element +// with a data-slot, then writes resources/onu/classes.php. Run with `pnpm onu-classes` +// after upgrading @javaabu/onu; CI fails when the file is out of date (`pnpm onu-classes --check`). +import { readFileSync, writeFileSync } from "node:fs" +import { createRequire } from "node:module" +import { createElement as h } from "react" +import { renderToStaticMarkup } from "react-dom/server" +import * as Onu from "@javaabu/onu" +import * as P from "@javaabu/onu/primitives" + +const require = createRequire(import.meta.url) +const { version } = require("@javaabu/onu/package.json") +const OUT = "resources/onu/classes.php" + +/** Every data-slot in the HTML → its class attribute (first occurrence wins). */ +function slots(html) { + const found = {} + for (const match of html.matchAll(/<([a-z0-9]+)\b([^>]*)>/g)) { + const attrs = match[2] + const slot = attrs.match(/\bdata-slot="([^"]+)"/)?.[1] + if (!slot || slot in found) continue + found[slot] = decode(attrs.match(/\bclass="([^"]*)"/)?.[1] ?? "") + } + return found +} + +function decode(value) { + return value + .replaceAll("&", "&") + .replaceAll(">", ">") + .replaceAll("<", "<") + .replaceAll(""", '"') + .replaceAll("'", "'") +} + +const render = (element) => slots(renderToStaticMarkup(element)) + +/** Classes of every element in order, for wrappers that have no data-slot. */ +function tags(element) { + return [...renderToStaticMarkup(element).matchAll(/<([a-z0-9]+)\b([^>]*)>/g)].map((match) => ({ + tag: match[1], + slot: match[2].match(/\bdata-slot="([^"]+)"/)?.[1], + class: decode(match[2].match(/\bclass="([^"]*)"/)?.[1] ?? ""), + })) +} + +/** The class of the first element after the given slot (or from the start) matching the tag. */ +function after(list, slot, tag, nth = 1) { + let index = slot ? list.findIndex((item) => item.slot === slot) : -1 + let seen = 0 + for (index += 1; index < list.length; index++) { + if (list[index].tag === tag && ++seen === nth) return list[index].class + } + throw new Error(`No <${tag}> #${nth} after ${slot}`) +} +const option = (value, label) => h("option", { value }, label) + +const classes = {} + +// Fields: label, required marker, help, errors, stacked and inline layouts. +classes.field = render(h(Onu.FormField, { label: "Title", name: "title", help: "Help", required: true }, h(Onu.TextInput, { name: "title" }))) +classes.field_invalid = render(h(Onu.FormField, { label: "Title", name: "title", error: "Required" }, h(Onu.TextInput, { name: "title" }))) +classes.field_inline = render(h(Onu.FormField, { label: "Title", name: "title", labelLayout: "inline" }, h(Onu.TextInput, { name: "title" }))) + +for (const [key, layout] of [["field", "stacked"], ["field_inline", "inline"]]) { + const list = tags(h(Onu.FormField, { label: "Title", name: "title", labelLayout: layout, error: "Required" }, h(Onu.TextInput, { name: "title" }))) + classes[key].grid = after(list, "form-field", "div", 1) + classes[key].label_row = after(list, "form-field", "div", 2) + classes[key].control = after(list, "label", "div", 1) + classes[key].error_line = after(list, "field-errors", "p", 1) + classes[key].error_icon = after(list, "field-errors", "svg", 1).replace(/lucide\S*\s?/g, "").trim() +} + +// Controls +classes.input = render(h(P.Input, { name: "title" })) +classes.input_group_text = after(tags(h(P.InputGroup, null, h(P.InputGroupAddon, null, h(P.InputGroupText, null, "MVR")))), "input-group-addon", "span", 1) +classes.input_group = render( + h(P.InputGroup, null, h(P.InputGroupAddon, null, h(P.InputGroupText, null, "MVR")), h(P.InputGroupInput, { name: "price" }), h(P.InputGroupAddon, { align: "inline-end" }, h(P.InputGroupButton, null, "Clear"))), +) +classes.textarea = render(h(P.Textarea, { name: "body" })) +classes.select = render(h(P.NativeSelect, { name: "status" }, h(P.NativeSelectOption, { value: "" }, "A"))) +classes.checkbox = render(h(P.Checkbox, { checked: true })) +classes.radio = render(h(P.RadioGroup, { defaultValue: "a" }, h(P.RadioGroupItem, { value: "a" }))) +// Onu's checkbox and radio are Radix buttons; Blade uses native inputs with the same classes, +// with the checked state from :checked instead of data-state. +const native = (value) => + `appearance-none ${value}`.replaceAll("data-[state=checked]:", "checked:").replaceAll("data-checked:", "checked:").replaceAll("aria-checked:", "checked:") +classes.checkbox.native = native(classes.checkbox.checkbox) +classes.radio.native = native(classes.radio["radio-group-item"]) +classes.choice = render(h(Onu.Checkbox, { label: "Agree", description: "Help", name: "agree" })) +{ + const list = tags(h(Onu.Checkbox, { label: "Agree", description: "Help", name: "agree" })) + classes.choice.control = after(list, "checkbox-field", "span", 1) + classes.choice.content = after(list, "checkbox", "div", 1) + classes.choice.description = after(list, "label", "p", 1) +} +classes.label = render(h(P.Label, null, "Title")) + +// Buttons: every variant x size. +const variants = ["default", "outline", "dashed", "secondary", "ghost", "destructive", "link", "success", "warning"] +const sizes = ["default", "xs", "sm", "lg", "icon", "icon-xs", "icon-sm", "icon-lg"] +classes.button = Object.fromEntries(variants.map((variant) => [variant, Object.fromEntries(sizes.map((size) => [size, Onu.buttonVariants({ variant, size })]))])) +classes.button_group = render(h(Onu.ButtonGroup, null, h(Onu.Button, null, "A"))) +classes.spinner = render(h(Onu.Button, { loading: true }, "Saving")) + +// Status badges and alerts, by tone. +const tones = ["neutral", "info", "success", "warning", "danger"] +classes.status = Object.fromEntries(["neutral", "info", "success", "warning", "danger", "pending"].map((tone) => [tone, Onu.statusBadgeVariants({ tone })])) +classes.alert = Object.fromEntries( + tones.map((tone) => [tone, render(h(Onu.Callout, { tone, title: "Heading", dismissible: true }, "Body"))]), +) + +// Display +classes.card = render( + h(P.Card, null, h(P.CardHeader, null, h(P.CardTitle, null, "Title"), h(P.CardDescription, null, "Subtitle")), h(P.CardContent, null, "Body"), h(P.CardFooter, null, "Footer")), +) +classes.accordion = render( + h(P.Accordion, { type: "single", defaultValue: "a", collapsible: true }, h(P.AccordionItem, { value: "a" }, h(P.AccordionTrigger, null, "Title"), h(P.AccordionContent, null, "Body"))), +) +classes.tabs = render( + h(P.Tabs, { defaultValue: "a" }, h(P.TabsList, null, h(P.TabsTrigger, { value: "a" }, "A"), h(P.TabsTrigger, { value: "b" }, "B")), h(P.TabsContent, { value: "a" }, "Body")), +) +const navTabs = h(Onu.NavTabs, { items: [{ label: "Active", href: "/a", count: 3 }, { label: "Other", href: "/b" }], currentHref: "/a" }) +classes.nav_tabs = render(navTabs) +classes.nav_tabs.list = after(tags(navTabs), "nav-tabs", "ul", 1) +classes.nav_tabs.item = after(tags(navTabs), "nav-tabs", "li", 1) +classes.modal = { + ...render(h(P.Dialog, { open: true }, h(Onu.ModalHeader, null, h(Onu.ModalTitle, null, "Title")), h(Onu.ModalBody, null, "Body"), h(Onu.ModalFooter, null, "Footer"))), + sizes: Object.fromEntries(["sm", "default", "lg", "xl", "full"].map((size) => [size, Onu.modalContentVariants({ size })])), +} +classes.table = render( + h(P.Table, null, h(P.TableHeader, null, h(P.TableRow, null, h(P.TableHead, null, "No"))), h(P.TableBody, null, h(P.TableRow, null, h(P.TableCell, null, "1")))), +) +classes.pagination = render( + h( + P.Pagination, + null, + h( + P.PaginationContent, + null, + h(P.PaginationItem, null, h(P.PaginationPrevious, { href: "#" })), + h(P.PaginationItem, null, h(P.PaginationLink, { href: "#", isActive: true }, "1")), + h(P.PaginationItem, null, h(P.PaginationLink, { href: "#" }, "2")), + h(P.PaginationItem, null, h(P.PaginationEllipsis)), + h(P.PaginationItem, null, h(P.PaginationNext, { href: "#" })), + ), + ), +) +classes.pagination.link = render(h(P.PaginationLink, { href: "#" }, "2"))["pagination-link"] +classes.pagination.link_active = render(h(P.PaginationLink, { href: "#", isActive: true }, "1"))["pagination-link"] +classes.pagination.previous = render(h(P.PaginationPrevious, { href: "#" }))["pagination-link"] +classes.pagination.next = render(h(P.PaginationNext, { href: "#" }))["pagination-link"] +classes.empty = render( + h(P.Empty, null, h(P.EmptyHeader, null, h(P.EmptyMedia, { variant: "icon" }, "i"), h(P.EmptyTitle, null, "Title"), h(P.EmptyDescription, null, "Text")), h(P.EmptyContent, null, "Action")), +) +classes.description_list = { + stacked: render(h(Onu.DescriptionList, { layout: "stacked" }, h(Onu.DescriptionListItem, { label: "Name" }, "Value"))), + inline: render(h(Onu.DescriptionList, { layout: "inline" }, h(Onu.DescriptionListItem, { label: "Name" }, "Value"))), +} +classes.badge = render(h(P.Badge, null, "3")) + +// PHP output +function php(value, indent = 1) { + const pad = " ".repeat(indent) + if (typeof value === "string") return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'` + const entries = Object.entries(value).map(([key, item]) => `${pad}'${key}' => ${php(item, indent + 1)},`) + return `[\n${entries.join("\n")}\n${" ".repeat(indent - 1)}]` +} + +const output = `js('js') + ->alias('@javaabu/forms-blade') + ->dist('../dist') + ->style(self::ONU_STYLES, 'css/onu-standalone.css') + ->style('select-styles', 'css/select.css') + ->style('dates-styles', 'css/dates.css') + ->script(self::CORE, 'js/forms.ts') + ->script(self::SELECT, 'js/select.ts', depends_on: [self::id(self::CORE), self::id('select-styles')]) + ->script(self::DATES, 'js/dates.ts', depends_on: [self::id(self::CORE), self::id('dates-styles')]) + ->script(self::FILES, 'js/files.ts', depends_on: [self::id(self::CORE)]) + ->script(self::WYSIWYG, 'js/wysiwyg.ts', depends_on: [self::id(self::CORE)]) + ->npm([ + 'flatpickr' => '^4.6', + 'tom-select' => '^2.4', + ], [ + '@javaabu/onu' => '^0.2.1', + 'tailwindcss' => '^4.0', + ]); + } + + public static function id(string $name): string + { + return self::PACKAGE . '::' . $name; + } + + /** + * Asset ids for the given names, honouring the forms.assets config. + * + * @param array $names + * @return array + */ + public static function ids(array $names, string $framework): array + { + if (! config('forms.assets.enabled', true)) { + return []; + } + + if ($framework === 'onu' && config('forms.assets.styles', true)) { + $names[] = self::ONU_STYLES; + } + + return array_values(array_unique(array_map(self::id(...), $names))); + } +} diff --git a/src/Commands/DiffViewsCommand.php b/src/Commands/DiffViewsCommand.php index 93d4977..f925482 100644 --- a/src/Commands/DiffViewsCommand.php +++ b/src/Commands/DiffViewsCommand.php @@ -2,6 +2,7 @@ namespace Javaabu\Forms\Commands; +use Exception; use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; @@ -16,22 +17,24 @@ class DiffViewsCommand extends Command public function handle(): int { - $filesystem = new Filesystem(); + $filesystem = new Filesystem; - $packagePath = __DIR__ . '/../../resources/views'; + $packagePath = __DIR__ . '/../../resources/views'; $publishedPath = resource_path('views/vendor/forms'); if (! $filesystem->isDirectory($packagePath)) { $this->error('Package view directory not found.'); + return 1; } if (! $filesystem->isDirectory($publishedPath)) { $this->info('No published views found in your application.'); + return 0; } - $packageFiles = $this->getViewFiles($packagePath, $filesystem); + $packageFiles = $this->getViewFiles($packagePath, $filesystem); $publishedFiles = $this->getViewFiles($publishedPath, $filesystem); $onlyRedundant = (bool) $this->option('only-redundant'); @@ -39,7 +42,7 @@ public function handle(): int $rows = []; $counts = [ 'redundant' => 0, - 'modified' => 0, + 'modified' => 0, ]; // keep track of redundant files (published) for optional deletion @@ -53,7 +56,7 @@ public function handle(): int continue; } - $packageContent = $filesystem->get($packageFiles[$relativePath]); + $packageContent = $filesystem->get($packageFiles[$relativePath]); $publishedContent = $filesystem->get($publishedRealPath); if ($packageContent === $publishedContent) { @@ -113,8 +116,8 @@ protected function getViewFiles(string $basePath, Filesystem $filesystem): array return []; } - $files = []; - $finder = new Finder(); + $files = []; + $finder = new Finder; $finder->files()->in($basePath)->name('*.blade.php'); @@ -132,8 +135,8 @@ protected function printSummary(array $counts): void { $this->newLine(); $this->info('Summary:'); - $this->line(' Redundant (can be deleted): '.$counts['redundant']); - $this->line(' Modified (customized override): '.$counts['modified']); + $this->line(' Redundant (can be deleted): ' . $counts['redundant']); + $this->line(' Modified (customized override): ' . $counts['modified']); } /** @@ -147,11 +150,13 @@ protected function deleteRedundantViews(array $redundantFiles, Filesystem $files if ($count === 0) { $this->info('No redundant views to delete.'); + return; } - if (! $this->confirm("Delete " . $count . " redundant view file(s) from your application?", false)) { + if (! $this->confirm('Delete ' . $count . ' redundant view file(s) from your application?', false)) { $this->info('No files were deleted.'); + return; } @@ -159,7 +164,7 @@ protected function deleteRedundantViews(array $redundantFiles, Filesystem $files try { $filesystem->delete($realPath); $this->line(" DELETED {$relativePath}"); - } catch (\Exception $e) { + } catch (Exception $e) { $this->line(" FAILED {$relativePath}: {$e->getMessage()}"); } } diff --git a/src/Commands/PublishViewCommand.php b/src/Commands/PublishViewCommand.php index 178f090..1341686 100644 --- a/src/Commands/PublishViewCommand.php +++ b/src/Commands/PublishViewCommand.php @@ -2,14 +2,17 @@ namespace Javaabu\Forms\Commands; +use Exception; use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; -use Symfony\Component\Finder\Finder; + use function Laravel\Prompts\confirm; use function Laravel\Prompts\info; use function Laravel\Prompts\search; use function Laravel\Prompts\select; +use Symfony\Component\Finder\Finder; + class PublishViewCommand extends Command { protected $signature = 'forms:publish-view @@ -22,10 +25,11 @@ public function handle() { $sourcePath = __DIR__ . '/../../resources/views'; $targetBase = resource_path('views/vendor/forms'); - $filesystem = new Filesystem(); + $filesystem = new Filesystem; - if (!$filesystem->isDirectory($sourcePath)) { + if (! $filesystem->isDirectory($sourcePath)) { $this->error('Source view directory not found.'); + return 1; } @@ -33,6 +37,7 @@ public function handle() if (empty($files)) { $this->error('No view files found to publish.'); + return 1; } @@ -45,6 +50,7 @@ public function handle() if ($selectedIndex === null) { $this->info('No file selected for publishing.'); + return 0; } @@ -59,7 +65,7 @@ public function handle() protected function getViewFiles(string $sourcePath, Filesystem $filesystem): array { $files = []; - $finder = new Finder(); + $finder = new Finder; try { $finder->files()->in($sourcePath)->name('*.blade.php'); @@ -76,12 +82,13 @@ protected function getViewFiles(string $sourcePath, Filesystem $filesystem): arr foreach ($tempFiles as $relativePath => $realPath) { $files[$count] = [ 'relative_path' => $relativePath, - 'real_path' => $realPath, + 'real_path' => $realPath, ]; $count++; } - } catch (\Exception $e) { + } catch (Exception $e) { $this->error('Error scanning view files: ' . $e->getMessage()); + return []; } @@ -124,9 +131,9 @@ protected function promptForFileSelection(array $files, string $targetBase, File ) : search( label: 'Which view would you like to publish?', - options: fn($search) => array_values(array_filter( + options: fn ($search) => array_values(array_filter( $options, - fn($choice) => str_contains(strtolower($choice), strtolower($search)) + fn ($choice) => str_contains(strtolower($choice), strtolower($search)) )), placeholder: 'Search...', scroll: 15 @@ -154,7 +161,7 @@ protected function publishFiles(array $selected, array $files, string $targetBas $overwritten = 0; foreach ($selected as $fileIndex) { - if (!isset($files[$fileIndex])) { + if (! isset($files[$fileIndex])) { continue; } @@ -167,7 +174,7 @@ protected function publishFiles(array $selected, array $files, string $targetBas $exists = $filesystem->exists($targetPath); - if ($exists && !$this->option('force')) { + if ($exists && ! $this->option('force')) { $shouldOverwrite = confirm( label: "File {$relativePath} already exists. Overwrite?", default: false, @@ -175,9 +182,10 @@ protected function publishFiles(array $selected, array $files, string $targetBas no: 'No, skip' ); - if (!$shouldOverwrite) { + if (! $shouldOverwrite) { $this->line(" SKIPPED {$relativePath}"); $skipped++; + continue; } @@ -189,7 +197,7 @@ protected function publishFiles(array $selected, array $files, string $targetBas $action = $exists ? 'UPDATED' : 'PUBLISHED'; $this->line(" {$action} {$relativePath}"); $published++; - } catch (\Exception $e) { + } catch (Exception $e) { $this->line(" FAILED {$relativePath}: " . $e->getMessage()); } } diff --git a/src/FormsDataBinder.php b/src/FormsDataBinder.php index 921ecc9..1196f28 100644 --- a/src/FormsDataBinder.php +++ b/src/FormsDataBinder.php @@ -14,8 +14,7 @@ class FormsDataBinder /** * Bind a target to the current instance * - * @param mixed $target - * @return void + * @param mixed $target */ public function bind($target): void { @@ -34,8 +33,6 @@ public function get() /** * Remove the last binding. - * - * @return void */ public function pop(): void { diff --git a/src/FormsServiceProvider.php b/src/FormsServiceProvider.php index efb67d7..979de86 100644 --- a/src/FormsServiceProvider.php +++ b/src/FormsServiceProvider.php @@ -4,10 +4,17 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; +use Javaabu\Forms\Assets\Assets; +use Javaabu\Forms\Commands\DiffViewsCommand; +use Javaabu\Forms\Commands\PublishViewCommand; use Javaabu\Forms\Http\Middleware\OverrideFormsDefaults; +use Javaabu\Manifest\Concerns\RegistersAssets; +use Javaabu\Manifest\Package; class FormsServiceProvider extends ServiceProvider { + use RegistersAssets; + /** * Bootstrap the application services. */ @@ -28,7 +35,9 @@ public function boot(): void ], 'forms-views'); } - $this->loadTranslationsFrom(__DIR__.'/../lang', 'forms'); + $this->registerAssets(Assets::PACKAGE, __DIR__ . '/../resources', fn (Package $package) => Assets::register($package)); + + $this->loadTranslationsFrom(__DIR__ . '/../lang', 'forms'); $this->loadViewsFrom(__DIR__ . '/../resources/views', 'forms'); @@ -52,15 +61,15 @@ public function register(): void $this->mergeConfig(); // register the data binder - $this->app->singleton(FormsDataBinder::class, fn () => new FormsDataBinder()); + $this->app->singleton(FormsDataBinder::class, fn () => new FormsDataBinder); app('router')->aliasMiddleware('forms', OverrideFormsDefaults::class); // Register custom artisan commands if ($this->app->runningInConsole()) { $this->commands([ - \Javaabu\Forms\Commands\PublishViewCommand::class, - \Javaabu\Forms\Commands\DiffViewsCommand::class, + PublishViewCommand::class, + DiffViewsCommand::class, ]); } } @@ -71,7 +80,7 @@ protected function mergeConfig(): void $this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'forms'); // Deep merge for nested 'framework' config - $default = require __DIR__.'/../config/config.php'; + $default = require __DIR__ . '/../config/config.php'; $default_frameworks = $default['frameworks']; foreach ($default_frameworks as $framework => $configs) { diff --git a/src/Frameworks/Bootstrap6.php b/src/Frameworks/Bootstrap6.php new file mode 100644 index 0000000..304c496 --- /dev/null +++ b/src/Frameworks/Bootstrap6.php @@ -0,0 +1,68 @@ + */ + protected array $themes = [ + 'primary' => 'primary', + 'secondary' => 'secondary', + 'success' => 'success', + 'danger' => 'danger', + 'warning' => 'warning', + 'info' => 'info', + 'light' => 'secondary', + 'dark' => 'inverse', + 'accent' => 'accent', + 'inverse' => 'inverse', + ]; + + public function name(): string + { + return 'bootstrap-6'; + } + + public function theme(string $color): string + { + return 'theme-' . ($this->themes[$color] ?? $color); + } + + public function button(string $color, ?string $size = null): string + { + $size_class = match ($size) { + null, '', 'md', 'default' => '', + 'icon' => ' btn-icon', + default => " btn-{$size}", + }; + + if ($color === 'link') { + return "btn-link{$size_class}"; + } + + if (Str::startsWith($color, 'outline-')) { + return 'btn-outline ' . $this->theme(Str::after($color, 'outline-')) . $size_class; + } + + if ($color === 'light') { + return 'btn-subtle ' . $this->theme($color) . $size_class; + } + + return 'btn-solid ' . $this->theme($color) . $size_class; + } + + public function badge(string $color): string + { + return 'badge ' . $this->theme($color); + } + + public function alert(string $type): string + { + return 'alert ' . $this->theme($type); + } +} diff --git a/src/Frameworks/Framework.php b/src/Frameworks/Framework.php new file mode 100644 index 0000000..31d1e41 --- /dev/null +++ b/src/Frameworks/Framework.php @@ -0,0 +1,28 @@ +) and size (sm, lg). + */ + public function button(string $color, ?string $size = null): string; + + /** + * Classes for a status badge of the given colour. + */ + public function badge(string $color): string; + + /** + * Classes for an alert of the given type. + */ + public function alert(string $type): string; +} diff --git a/src/Frameworks/Frameworks.php b/src/Frameworks/Frameworks.php new file mode 100644 index 0000000..5f19ae4 --- /dev/null +++ b/src/Frameworks/Frameworks.php @@ -0,0 +1,51 @@ +> */ + protected static array $frameworks = [ + 'onu' => Onu::class, + 'bootstrap-6' => Bootstrap6::class, + ]; + + /** @var array */ + protected static array $instances = []; + + /** + * Registers a framework, so components can render with framework="$name" + * once its views exist in resources/views/vendor/forms/$name. + * + * @param class-string $framework + */ + public static function register(string $name, string $framework): void + { + static::$frameworks[$name] = $framework; + + unset(static::$instances[$name]); + } + + public static function get(string $name): Framework + { + if (isset(static::$instances[$name])) { + return static::$instances[$name]; + } + + $framework = static::$frameworks[$name] ?? null; + + if (! $framework) { + throw new InvalidArgumentException("Unknown forms framework [{$name}]. Supported: " . implode(', ', static::names()) . '.'); + } + + return static::$instances[$name] = app($framework); + } + + /** @return array */ + public static function names(): array + { + return array_keys(static::$frameworks); + } +} diff --git a/src/Frameworks/Onu.php b/src/Frameworks/Onu.php new file mode 100644 index 0000000..2fda229 --- /dev/null +++ b/src/Frameworks/Onu.php @@ -0,0 +1,91 @@ +|null */ + protected ?array $classes = null; + + /** @var array */ + protected array $button_variants = [ + 'primary' => 'default', + 'secondary' => 'secondary', + 'success' => 'success', + 'danger' => 'destructive', + 'warning' => 'warning', + 'info' => 'default', + 'light' => 'outline', + 'dark' => 'secondary', + 'link' => 'link', + ]; + + /** @var array */ + protected array $tones = [ + 'primary' => 'info', + 'info' => 'info', + 'success' => 'success', + 'warning' => 'warning', + 'danger' => 'danger', + 'secondary' => 'neutral', + 'light' => 'neutral', + 'dark' => 'neutral', + ]; + + public function name(): string + { + return 'onu'; + } + + /** + * Classes Onu renders for a component part, e.g. "input" or "button.destructive.sm". + */ + public function classes(string $key, string $default = ''): string + { + return data_get($this->all(), $key, $default); + } + + /** @return array */ + public function all(): array + { + return $this->classes ??= require __DIR__ . '/../../resources/onu/classes.php'; + } + + public function buttonVariant(string $color): string + { + if (Str::startsWith($color, 'outline-')) { + return 'outline'; + } + + return $this->button_variants[$color] ?? 'default'; + } + + public function tone(string $color): string + { + return $this->tones[$color] ?? 'neutral'; + } + + public function button(string $color, ?string $size = null): string + { + $variant = $this->buttonVariant($color); + $size = in_array($size, [null, '', 'md'], true) ? 'default' : $size; + + return $this->classes("button.{$variant}.{$size}", $this->classes("button.{$variant}.default")); + } + + public function badge(string $color): string + { + return $this->classes('status.' . $this->tone($color)); + } + + public function alert(string $type): string + { + return $this->classes('alert.' . $this->tone($type) . '.callout'); + } +} diff --git a/src/Http/Middleware/OverrideFormsDefaults.php b/src/Http/Middleware/OverrideFormsDefaults.php index 3174f54..839511b 100644 --- a/src/Http/Middleware/OverrideFormsDefaults.php +++ b/src/Http/Middleware/OverrideFormsDefaults.php @@ -11,15 +11,12 @@ class OverrideFormsDefaults /** * Handle the incoming request. * - * @param Request $request - * @param Closure $next - * @param string $framework * @return Response */ public function handle(Request $request, Closure $next, string $framework) { config([ - 'forms.framework' => $framework + 'forms.framework' => $framework, ]); return $next($request); diff --git a/src/Support/HandlesBoundValues.php b/src/Support/HandlesBoundValues.php index aa35b4b..64cd0ee 100644 --- a/src/Support/HandlesBoundValues.php +++ b/src/Support/HandlesBoundValues.php @@ -28,8 +28,6 @@ trait HandlesBoundValues /** * Get an instance of FormDataBinder. - * - * @return FormsDataBinder */ protected function getFormsDataBinder(): FormsDataBinder { @@ -39,10 +37,13 @@ protected function getFormsDataBinder(): FormsDataBinder /** * Bind model from props */ + protected bool $bound_model = false; + protected function bindModel($model) { if ($model) { $this->getFormsDataBinder()->bind($model); + $this->bound_model = true; } else { $model = $this->getBoundTarget(); } @@ -50,6 +51,20 @@ protected function bindModel($model) $this->model = $model; } + /** + * Removes the model this component bound, once its content has rendered, so + * fields after the component don't pick it up. Called at the end of the view. + */ + public function unbindModel(): string + { + if ($this->bound_model) { + $this->getFormsDataBinder()->pop(); + $this->bound_model = false; + } + + return ''; + } + /** * Get the latest bound target. * @@ -63,8 +78,7 @@ protected function getBoundTarget() /** * Get an item from the latest bound target. * - * @param mixed $bind - * @param string $name + * @param mixed $bind * @return mixed */ protected function getBoundValue($bind, string $name) @@ -91,9 +105,6 @@ protected function getBoundValue($bind, string $name) /** * Formats a DateTimeInterface if the key is specified as a date or datetime in the model. * - * @param \Illuminate\Database\Eloquent\Model $model - * @param string $key - * @param DateTimeInterface $date * @return void */ protected function formatDateTime(Model $model, string $key, DateTimeInterface $date) @@ -134,8 +145,7 @@ protected function isCustomDateTimeCast($cast) /** * Returns an array with the attached keys. * - * @param mixed $bind - * @param string $name + * @param mixed $bind * @return mixed */ protected function getAttachedKeysFromRelation($bind, string $name) diff --git a/src/Support/HandlesMediaValues.php b/src/Support/HandlesMediaValues.php index edd87bd..f1cf5da 100644 --- a/src/Support/HandlesMediaValues.php +++ b/src/Support/HandlesMediaValues.php @@ -47,6 +47,7 @@ protected function getFileNameFromUrl(?string $url): string { if ($url) { $path = parse_url($url, PHP_URL_PATH); + return basename($path); } diff --git a/src/Support/HandlesValidationErrors.php b/src/Support/HandlesValidationErrors.php index dfe43cb..e3f27ba 100644 --- a/src/Support/HandlesValidationErrors.php +++ b/src/Support/HandlesValidationErrors.php @@ -14,10 +14,6 @@ trait HandlesValidationErrors /** * Returns a boolean whether the given attribute has an error, * and they should be shown. - * - * @param string $name - * @param string $bag - * @return bool */ public function hasErrorAndShow(string $name, string $bag = 'default'): bool { @@ -28,23 +24,22 @@ public function hasErrorAndShow(string $name, string $bag = 'default'): bool /** * Getter for the ErrorBag. - * - * @param string $bag - * @return \Illuminate\Contracts\Support\MessageBag */ protected function getErrorBag(string $bag = 'default'): MessageBag { - $bags = View::shared('errors', fn () => request()->session()->get('errors', new ViewErrorBag())); + $bags = View::shared('errors', function () { + $request = request(); + + return $request->hasSession() + ? $request->session()->get('errors', new ViewErrorBag) + : new ViewErrorBag; + }); return $bags->getBag($bag); } /** * Returns a boolean whether the given attribute has an error. - * - * @param string $name - * @param string $bag - * @return bool */ public function hasError(string $name, string $bag = 'default'): bool { diff --git a/src/Support/Icons/FontAwesomeIcons.php b/src/Support/Icons/FontAwesomeIcons.php index c41321e..c371e34 100644 --- a/src/Support/Icons/FontAwesomeIcons.php +++ b/src/Support/Icons/FontAwesomeIcons.php @@ -4,9 +4,6 @@ abstract class FontAwesomeIcons extends Icons { - /** - * @var array - */ protected static array $icons = [ 'abacus' => 'abacus', 'acorn' => 'acorn', @@ -1826,9 +1823,5 @@ abstract class FontAwesomeIcons extends Icons 'yin-yang' => 'yin-yang', ]; - /** - * @var string - */ protected static string $icon_prefix = 'fa'; - } diff --git a/src/Support/Icons/HasIcon.php b/src/Support/Icons/HasIcon.php index 56d7d58..0a815fb 100644 --- a/src/Support/Icons/HasIcon.php +++ b/src/Support/Icons/HasIcon.php @@ -1,4 +1,5 @@ |null */ + protected static ?array $icons = null; + + /** + * The inner markup of a bundled icon, or null when the name isn't bundled. + */ + public static function get(string $name): ?string + { + return static::all()[$name] ?? null; + } + + public static function has(string $name): bool + { + return array_key_exists($name, static::all()); + } + + /** @return array */ + public static function all(): array + { + return static::$icons ??= require __DIR__ . '/../../../resources/icons/icons.php'; + } +} diff --git a/src/Support/MimeTypes.php b/src/Support/MimeTypes.php new file mode 100644 index 0000000..5260b7d --- /dev/null +++ b/src/Support/MimeTypes.php @@ -0,0 +1,155 @@ +>, extensions: array, file_size_settings: array>}|null */ + protected static ?array $data = null; + + /** + * Whether to read javaabu/helpers when it's installed. Tests turn this off. + */ + public static bool $use_helpers = true; + + protected static function helpers(): bool + { + return static::$use_helpers && class_exists(AllowedMimeTypes::class); + } + + /** @return array{mime_types: array>, extensions: array, file_size_settings: array>} */ + protected static function data(): array + { + return static::$data ??= require __DIR__ . '/../../resources/data/mime-types.php'; + } + + /** + * @param string|array $types e.g. "image", "document" or ["image", "pdf"] + * @return array + */ + public static function mimeTypes(string|array $types = ''): array + { + if (static::helpers()) { + return AllowedMimeTypes::getAllowedMimeTypes($types); + } + + $all = static::data()['mime_types']; + + if (! $types) { + return Arr::flatten($all); + } + + return Arr::flatten(array_map(fn ($type) => $all[$type] ?? [], Arr::wrap($types))); + } + + /** + * @param array $mime_types + * @return array + */ + public static function extensions(array $mime_types): array + { + if (static::helpers()) { + return AllowedMimeTypes::getExtensions($mime_types); + } + + $extensions = static::data()['extensions']; + + return array_values(array_unique(array_filter(array_map(fn ($mime_type) => $extensions[$mime_type] ?? null, $mime_types)))); + } + + /** + * The largest size (KB) allowed for any of the types: from settings when javaabu/settings + * is installed (max_{type}_file_size, then the type's size setting), else + * forms.defaults.max_upload_file_size, else 10240. + * + * @param string|array $types + */ + public static function maxFileSize(string|array $types = ''): int + { + if (static::helpers() && function_exists('get_setting')) { + return AllowedMimeTypes::getMaxFileSize($types); + } + + $types = $types ? Arr::wrap($types) : array_keys(static::data()['mime_types']); + $max_size = 0; + + foreach ($types as $type) { + $size = (int) Settings::get("max_{$type}_file_size", 0) + ?: (int) Settings::get(static::fileSizeSetting($type), 0) + ?: (int) Settings::get('max_upload_file_size', 10240); + + $max_size = max($max_size, $size); + } + + return $max_size; + } + + public static function fileSizeSetting(string $type): string + { + foreach (static::data()['file_size_settings'] as $setting => $types) { + if (in_array($type, $types)) { + return $setting; + } + } + + return 'max_upload_file_size'; + } + + /** + * @param array $mime_types + */ + public static function register(string $type, array $mime_types): void + { + static::data(); + + static::$data['mime_types'][$type] = $mime_types; + + if (class_exists(AllowedMimeTypes::class)) { + AllowedMimeTypes::registerMimeTypes($type, $mime_types); + } + } + + /** + * @param array $extensions mime type => extension + */ + public static function registerExtensions(array $extensions): void + { + static::data(); + + static::$data['extensions'] = array_merge(static::$data['extensions'], $extensions); + + if (class_exists(AllowedMimeTypes::class)) { + AllowedMimeTypes::registerMimeTypeExtensions($extensions); + } + } + + /** + * A size in KB for people: "512 KB", "1.5 MB", "2 GB", with translated units. + */ + public static function formatFileSize(int $kb): string + { + if ($kb < 0) { + return ''; + } + + $bytes = $kb * 1024; + + [$size, $unit] = match (true) { + $bytes < 1000000 => [$bytes / 1024, 'kb'], + $bytes < 1000000000 => [$bytes / 1024 / 1024, 'mb'], + default => [$bytes / 1024 / 1024 / 1024, 'gb'], + }; + + return round($size, 2) . trans("forms::strings.{$unit}"); + } +} diff --git a/src/Support/Settings.php b/src/Support/Settings.php new file mode 100644 index 0000000..f3cc734 --- /dev/null +++ b/src/Support/Settings.php @@ -0,0 +1,30 @@ +type = $type; diff --git a/src/Views/Components/BulkActions.php b/src/Views/Components/BulkActions.php index 3149f1a..d059749 100644 --- a/src/Views/Components/BulkActions.php +++ b/src/Views/Components/BulkActions.php @@ -11,6 +11,7 @@ class BulkActions extends Component protected string $view = 'bulk-actions'; public string $model; + public array $actions; /** diff --git a/src/Views/Components/Button.php b/src/Views/Components/Button.php index 075b259..e797027 100644 --- a/src/Views/Components/Button.php +++ b/src/Views/Components/Button.php @@ -6,23 +6,32 @@ class Button extends Component { protected string $view = 'button'; - public string $type; - public string $color; - /** - * Create a new component instance. - * - * @return void + * @param string $color Bootstrap colour name: primary, danger, outline-secondary, link, ... + * @param string $size sm, md (default), lg or icon + * @param string $startIcon Icon before the label + * @param string $endIcon Icon after the label + * @param bool $animate Show a spinner once the form submits + * @param bool $loading Show a spinner and block clicks now */ public function __construct( - string $type = '', - string $color = 'primary', + public string $type = '', + public string $color = 'primary', public bool $animate = false, - string $framework = '' + public string $size = '', + public string $startIcon = '', + public string $endIcon = '', + public bool $loading = false, + string $framework = '', ) { parent::__construct($framework); + } - $this->type = $type; - $this->color = $color; + /** + * The size name the framework understands: sm, lg, icon, or null for the default. + */ + public function frameworkSize(): ?string + { + return in_array($this->size, ['', 'md', 'default']) ? null : $this->size; } } diff --git a/src/Views/Components/ButtonGroup.php b/src/Views/Components/ButtonGroup.php index fdc47c1..9518cc7 100644 --- a/src/Views/Components/ButtonGroup.php +++ b/src/Views/Components/ButtonGroup.php @@ -5,6 +5,7 @@ class ButtonGroup extends Component { protected string $view = 'button-group'; + public bool $inline; /** diff --git a/src/Views/Components/Checkbox.php b/src/Views/Components/Checkbox.php index 2ae579c..8a8ea99 100644 --- a/src/Views/Components/Checkbox.php +++ b/src/Views/Components/Checkbox.php @@ -2,6 +2,7 @@ namespace Javaabu\Forms\Views\Components; +use BackedEnum; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -41,7 +42,7 @@ public function __construct( default: $default, showErrors: $showErrors, showLabel: $showLabel, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, @@ -66,12 +67,33 @@ public function __construct( } if (is_array($boundValue)) { - $this->checked = in_array($value, $boundValue); + $this->checked = in_array($value instanceof BackedEnum ? $value->value : $value, array_map(fn ($item) => $item instanceof BackedEnum ? $item->value : $item, $boundValue)); return; } - $this->checked = is_null($boundValue) ? $default : $boundValue; + $this->checked = is_null($boundValue) ? (bool) $default : $this->matchesValue($boundValue, $value); } } + + /** + * A boolean-like bound value (true, 1, "1", false, 0) checks the box by truthiness, as in v1; + * any other value checks it when it equals the checkbox's value (backed enums by their value). + */ + protected function matchesValue(mixed $bound, mixed $value): bool + { + if ($bound instanceof BackedEnum) { + $bound = $bound->value; + } + + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (is_bool($bound) || in_array($bound, [0, 1, '0', '1'], true)) { + return (bool) $bound; + } + + return (string) $bound === (string) $value; + } } diff --git a/src/Views/Components/Component.php b/src/Views/Components/Component.php index 225db49..e73cc2f 100644 --- a/src/Views/Components/Component.php +++ b/src/Views/Components/Component.php @@ -2,10 +2,15 @@ namespace Javaabu\Forms\Views\Components; +use Illuminate\Support\Facades\View; use Illuminate\Support\Str; use Illuminate\View\Component as BaseComponent; +use Javaabu\Forms\Assets\Assets; +use Javaabu\Forms\Frameworks\Framework; +use Javaabu\Forms\Frameworks\Frameworks; +use Javaabu\Manifest\Contracts\DeclaresAssets; -abstract class Component extends BaseComponent +abstract class Component extends BaseComponent implements DeclaresAssets { /** * ID for this component. @@ -16,18 +21,110 @@ abstract class Component extends BaseComponent /** * Framework used for this component - * - * @var string */ public string $framework; /** * The view to be used - * - * @var string */ protected string $view; + /** + * Classes for the field's form group (wrapper). Read from the form-group-class attribute. + */ + public string $formGroupClass = ''; + + /** + * Type Thaana from a Latin keyboard. Read from the thaana attribute. + */ + public bool $thaana = false; + + /** + * Grow a textarea with its content. Read from the auto-resize attribute. + */ + public bool $autoResize = false; + + /** + * Most decimals a number input keeps. Read from the decimals attribute. + */ + public ?int $decimals = null; + + /** + * Attributes every field accepts without a constructor parameter, mapped to their property. + * + * @var array + */ + protected array $field_options = [ + 'form-group-class' => 'formGroupClass', + 'formGroupClass' => 'formGroupClass', + 'thaana' => 'thaana', + 'auto-resize' => 'autoResize', + 'autoResize' => 'autoResize', + 'decimals' => 'decimals', + ]; + + /** + * Pulls the shared field options out of the attributes, so they reach the view as + * properties instead of HTML attributes. + */ + public function withAttributes(array $attributes) + { + foreach ($this->field_options as $attribute => $property) { + if (! array_key_exists($attribute, $attributes)) { + continue; + } + + $this->setFieldOption($property, $attributes[$attribute]); + + unset($attributes[$attribute]); + } + + return parent::withAttributes($attributes); + } + + protected function setFieldOption(string $property, mixed $value): void + { + $this->{$property} = match ($property) { + 'thaana', 'autoResize' => filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? (bool) $value, + 'decimals' => is_numeric($value) ? (int) $value : null, + default => (string) $value, + }; + } + + /* + * The shared field options are read through methods in views: Blade captures a + * component's properties before withAttributes() runs, but calls methods lazily. + */ + + public function groupClass(): string + { + return $this->formGroupClass; + } + + public function shouldAutoResize(): bool + { + return $this->autoResize; + } + + public function decimalPlaces(): ?int + { + return $this->decimals; + } + + /** + * HTML attributes for a Thaana field, as Onu's TextInput renders them. + * + * @return array + */ + public function thaanaAttributes(): array + { + if (! $this->thaana) { + return []; + } + + return ['dir' => 'rtl', 'lang' => 'dv', 'data-thaana' => '']; + } + /** * Create a new component instance. * @@ -42,16 +139,44 @@ public function __construct(string $framework = '') $this->framework = $framework; } + /** + * The package assets (Assets::* names) this component needs on the page. + * javaabu/manifest loads them when the component renders. + * + * @return array + */ + public function assets(): array + { + return [Assets::CORE]; + } + + /** @return array */ + public function requiredAssets(): array + { + return Assets::ids($this->assets(), $this->framework); + } + + /** + * Resolves a logical icon (date, clear, upload, ...) from the forms.icons config. + */ public function getFrameworkIcon(string $icon): string { - $icon_prefix = $this->frameworkConfig('icon-prefix'); + return $this->frameworkConfig("icons.{$icon}") + ?? config("forms.icons.{$icon}") + ?? $icon; + } - return "$icon_prefix $icon"; + /** + * The framework's class map (button colours, badges, alerts). + */ + public function frameworkClasses(): Framework + { + return Frameworks::get($this->framework); } - public function frameworkConfig(string $config, $default = null) + public function frameworkConfig(string $config, mixed $default = null): mixed { - return config('forms.frameworks.' . $this->framework . '.' . $config, $default); + return config("forms.frameworks.{$this->framework}.{$config}", $default); } public function getView(): string @@ -60,21 +185,23 @@ public function getView(): string } /** - * {@inheritDoc} + * The framework's own view, or the shared one in forms::common when the framework has none. */ public function render() { - $view = $this->getView(); + $view = str_replace('{framework}', $this->framework, $this->getView()); + + if (View::exists($view)) { + return $view; + } - $framework = $this->framework; + $common = str_replace('{framework}', 'common', $this->getView()); - return str_replace('{framework}', $framework, $view); + return View::exists($common) ? $common : $view; } /** * Generates an ID, once, for this component. - * - * @return string */ public function id(): string { @@ -91,8 +218,6 @@ public function id(): string /** * Generates an ID by the name attribute. - * - * @return string */ protected function generateIdByName(): string { @@ -101,8 +226,6 @@ protected function generateIdByName(): string /** * Generates an ID by the name attribute. - * - * @return string */ public function stringToId($text): string { @@ -111,8 +234,6 @@ public function stringToId($text): string /** * Generates a label, once, for this component. - * - * @return string */ public function label(): string { @@ -125,24 +246,19 @@ public function label(): string /** * Generates a label by the name attribute. - * - * @return string */ protected function generateLabelByName(): string { return trans(Str::of(str_replace(['[', ']'], ['_', ''], Str::before($this->name, '[]'))) - ->camel() - ->snake() - ->replace('_', ' ') - ->title() - ->toString()); + ->camel() + ->snake() + ->replace('_', ' ') + ->title() + ->toString()); } /** * Converts a bracket-notation to a dotted-notation - * - * @param string $name - * @return string */ protected static function convertBracketsToDots(string $name): string { diff --git a/src/Views/Components/ConditionalLink.php b/src/Views/Components/ConditionalLink.php index b6a71fd..7975931 100644 --- a/src/Views/Components/ConditionalLink.php +++ b/src/Views/Components/ConditionalLink.php @@ -7,14 +7,19 @@ class ConditionalLink extends Component { - use HandlesBoundValues; use FormatsValues; + use HandlesBoundValues; protected string $view = 'conditional-link'; + public bool $multiline; + public bool $showLink; + public string $name; + public string $url; + public $value; /** @@ -37,7 +42,7 @@ public function __construct( parent::__construct($framework); $this->url = $url; - $this->showLink = is_null($showLink) && $can ? auth()->guard($guard)->user()->can($can, $arg) : (bool) $showLink; + $this->showLink = is_null($showLink) && $can ? (bool) auth()->guard($guard)->user()?->can($can, $arg) : (bool) $showLink; $this->name = $name; $this->multiline = $multiline; $this->value = ! is_null($value) ? $value : ($name ? $this->getBoundValue($model, $name) : ''); diff --git a/src/Views/Components/ConditionalWrapper.php b/src/Views/Components/ConditionalWrapper.php index ec49ce0..1c7b4a2 100644 --- a/src/Views/Components/ConditionalWrapper.php +++ b/src/Views/Components/ConditionalWrapper.php @@ -15,14 +15,13 @@ class ConditionalWrapper extends Component */ public function __construct( public string $enableElem, - public $enableValue, - public bool $hideFields = false, - public bool $disable = false, - public bool $enableCheckbox = false, - ?bool $jsonEncode = null, - string $framework = '' - ) - { + public $enableValue = null, + public bool $hideFields = false, + public bool $disable = false, + public bool $enableCheckbox = false, + ?bool $jsonEncode = null, + string $framework = '' + ) { parent::__construct($framework); $this->jsonEncode = is_null($jsonEncode) ? is_array($this->enableValue) : $jsonEncode; diff --git a/src/Views/Components/Date.php b/src/Views/Components/Date.php index 3444fb3..4775fe3 100644 --- a/src/Views/Components/Date.php +++ b/src/Views/Components/Date.php @@ -4,13 +4,16 @@ use DateTimeInterface; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Carbon; +use Javaabu\Forms\Assets\Assets; class Date extends Input { public string $icon; + public string $iconWrapperClass; + public string $clearIcon; + public string $clearBtnClass; /** @@ -32,7 +35,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -60,10 +63,10 @@ public function __construct( framework: $framework, ); - $this->icon = $icon ?: $this->getFrameworkIcon($this->frameworkConfig('date-icon')); - $this->clearIcon = $clearIcon ?: $this->getFrameworkIcon($this->frameworkConfig('date-clear-icon')); - $this->clearBtnClass = $clearBtnClass ?: $this->frameworkConfig('date-clear-btn-class'); - $this->iconWrapperClass = $iconWrapperClass ?: $this->frameworkConfig('date-icon-wrapper-class'); + $this->icon = $icon ?: $this->getFrameworkIcon('date'); + $this->clearIcon = $clearIcon ?: $this->getFrameworkIcon('clear'); + $this->clearBtnClass = $clearBtnClass ?: $this->frameworkConfig('date_clear_btn_class', ''); + $this->iconWrapperClass = $iconWrapperClass ?: $this->frameworkConfig('date_icon_wrapper_class', ''); } public function datePickerClass(): string @@ -76,12 +79,30 @@ public function isDateInput(): bool return true; } + /** + * The format bound dates are shown in when date-format isn't given: the format the picker uses. + */ + public function defaultDateFormat(): string + { + return 'Y-m-d'; + } + protected function formatDateTime(Model $model, string $key, DateTimeInterface $date) { - if (! $this->dateFormat) { + if ($this->dateFormat) { + return $date->format($this->dateFormat); + } + + if (config('forms.use_eloquent_date_casting')) { return parent::formatDateTime($model, $key, $date); } - return $date->format($this->dateFormat); + return $date->format($this->defaultDateFormat()); + } + + /** @return array */ + public function assets(): array + { + return [Assets::CORE, Assets::DATES]; } } diff --git a/src/Views/Components/Datetime.php b/src/Views/Components/Datetime.php index 70b3543..cc41850 100644 --- a/src/Views/Components/Datetime.php +++ b/src/Views/Components/Datetime.php @@ -22,7 +22,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -46,7 +46,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, @@ -56,7 +56,7 @@ public function __construct( ); if (! $icon) { - $this->icon = $this->getFrameworkIcon($this->frameworkConfig('datetime-icon')); + $this->icon = $this->getFrameworkIcon('datetime'); } } @@ -64,4 +64,9 @@ public function datePickerClass(): string { return 'datetime-picker'; } + + public function defaultDateFormat(): string + { + return 'Y-m-d H:i'; + } } diff --git a/src/Views/Components/Email.php b/src/Views/Components/Email.php index 5f6c2a2..228f731 100644 --- a/src/Views/Components/Email.php +++ b/src/Views/Components/Email.php @@ -17,7 +17,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -36,7 +36,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, diff --git a/src/Views/Components/ErrorSummary.php b/src/Views/Components/ErrorSummary.php new file mode 100644 index 0000000..f6eeb54 --- /dev/null +++ b/src/Views/Components/ErrorSummary.php @@ -0,0 +1,59 @@ + $labels Field labels by name, e.g. ['email' => 'Email'] + */ + public function __construct( + public string $title = '', + public string $bag = 'default', + public array $labels = [], + string $framework = '', + ) { + parent::__construct($framework); + } + + /** + * @return array}> + */ + public function items(): array + { + $items = []; + + foreach ($this->getErrorBag($this->bag)->messages() as $name => $messages) { + $items[] = [ + 'name' => $name, + 'id' => $this->idForErrorKey($name), + 'label' => $this->labels[$name] ?? '', + 'messages' => $messages, + ]; + } + + return $items; + } + + public function heading(): string + { + return $this->title ?: trans('forms::strings.error_summary_title'); + } + + /** + * The id javaabu/forms generates for the field an error key belongs to: address.city → address-city. + */ + protected function idForErrorKey(string $key): string + { + return Str::of($key)->replaceMatches('/\.\d+$/', '')->replace('.', '-')->toString(); + } +} diff --git a/src/Views/Components/Errors.php b/src/Views/Components/Errors.php index a4e64f3..850490c 100644 --- a/src/Views/Components/Errors.php +++ b/src/Views/Components/Errors.php @@ -3,11 +3,16 @@ namespace Javaabu\Forms\Views\Components; use Illuminate\Support\Str; +use Javaabu\Forms\Support\HandlesValidationErrors; class Errors extends Component { + use HandlesValidationErrors; + protected string $view = 'errors'; + public string $name; + public string $bag; /** @@ -18,6 +23,7 @@ class Errors extends Component public function __construct( string $name = '', string $bag = 'default', + public bool $firstOnly = true, string $framework = '' ) { parent::__construct($framework); @@ -26,4 +32,20 @@ public function __construct( $this->bag = $bag; } + + /** + * The messages to show: the first, like @error, or all of them when first-only is false. + * + * @return array + */ + public function messages(): array + { + $errors = $this->getErrorBag($this->bag); + + $messages = $errors->get($this->name) ?: $errors->get($this->name . '.*'); + + $messages = collect($messages)->flatten()->all(); + + return $this->firstOnly ? array_slice($messages, 0, 1) : $messages; + } } diff --git a/src/Views/Components/File.php b/src/Views/Components/File.php index 8c80288..6b28667 100644 --- a/src/Views/Components/File.php +++ b/src/Views/Components/File.php @@ -3,8 +3,9 @@ namespace Javaabu\Forms\Views\Components; use Illuminate\Support\Arr; +use Javaabu\Forms\Assets\Assets; use Javaabu\Forms\Support\HandlesMediaValues; -use Javaabu\Helpers\Media\AllowedMimeTypes; +use Javaabu\Forms\Support\MimeTypes; class File extends Input { @@ -13,16 +14,27 @@ class File extends Input use HandlesMediaValues; public array $fileTypes; + public array $mimetypes; + public string $collection; + public string $conversion; + public string $fileInputClass; + public array $extensions; + public int $maxSize; + public bool $showHint; + public string $fileName = ''; + public string $clearIcon; + public string $downloadIcon; + public string $uploadIcon; /** @@ -31,31 +43,32 @@ class File extends Input * @return void */ public function __construct( - string $name, - string $label = '', - string|array $type = 'document', + string $name, + string $label = '', + string|array $type = 'document', null|string|array $mimetypes = null, null|string|array $extensions = null, - ?int $maxSize = null, - string $collection = '', - string $conversion = '', - string $fileInputClass = '', - string $clearIcon = '', - string $downloadIcon = '', - string $uploadIcon = '', - $model = null, - $default = null, - bool $showHint = true, + ?int $maxSize = null, + string $collection = '', + string $conversion = '', + string $fileInputClass = '', + string $clearIcon = '', + string $downloadIcon = '', + string $uploadIcon = '', + $model = null, + $default = null, + bool $showHint = true, bool $showErrors = true, bool $showLabel = true, bool $required = false, public bool $disabled = false, public bool $ignoreAccessor = false, - public bool $upload = false, + public bool $upload = false, bool $inline = false, string $inlineLabelClass = '', string $inlineInputClass = '', bool $showJsErrors = false, + public bool $multiple = false, string $framework = '' ) { $this->showHint = $showHint; @@ -63,9 +76,9 @@ public function __construct( $this->conversion = $conversion; $this->fileInputClass = $fileInputClass; $this->fileTypes = Arr::wrap($type); - $this->mimetypes = $mimetypes ? Arr::wrap($mimetypes) : AllowedMimeTypes::getAllowedMimeTypes($this->fileTypes); - $this->extensions = $extensions ? Arr::wrap($extensions) : AllowedMimeTypes::getExtensions($this->mimetypes); - $this->maxSize = $maxSize ?: AllowedMimeTypes::getMaxFileSize($this->fileTypes); + $this->mimetypes = $mimetypes ? Arr::wrap($mimetypes) : MimeTypes::mimeTypes($this->fileTypes); + $this->extensions = $extensions ? Arr::wrap($extensions) : MimeTypes::extensions($this->mimetypes); + $this->maxSize = $maxSize ?: MimeTypes::maxFileSize($this->fileTypes); parent::__construct( $name, @@ -75,7 +88,7 @@ public function __construct( default: $default, showErrors: $showErrors, showLabel: $showLabel, - required:$required, + required: $required, inline: $inline, floating: false, inlineLabelClass: $inlineLabelClass, @@ -84,19 +97,19 @@ public function __construct( framework: $framework ); - $this->downloadIcon = $downloadIcon ?: $this->getFrameworkIcon($this->frameworkConfig('file-download-icon')); - $this->uploadIcon = $uploadIcon ?: $this->getFrameworkIcon($this->frameworkConfig('file-upload-icon')); - $this->clearIcon = $clearIcon ?: $this->getFrameworkIcon($this->frameworkConfig('file-clear-icon')); + $this->downloadIcon = $downloadIcon ?: $this->getFrameworkIcon('download'); + $this->uploadIcon = $uploadIcon ?: $this->getFrameworkIcon('upload'); + $this->clearIcon = $clearIcon ?: $this->getFrameworkIcon('clear'); } public function getHint(): string { return trans('forms::strings.file_hint', [ 'types' => collect($this->extensions) - ->map(fn($ext) => '.' . $ext) - ->join(trans('forms::strings.text_list_separator'), trans('forms::strings.text_list_separator_last_glue')), + ->map(fn ($ext) => '.' . $ext) + ->join(trans('forms::strings.text_list_separator'), trans('forms::strings.text_list_separator_last_glue')), - 'size' => format_file_size($this->maxSize), + 'size' => MimeTypes::formatFileSize($this->maxSize), ]); } @@ -104,4 +117,10 @@ public function isFileInput(): bool { return true; } + + /** @return array */ + public function assets(): array + { + return [Assets::CORE, Assets::FILES]; + } } diff --git a/src/Views/Components/FileUpload.php b/src/Views/Components/FileUpload.php index 75fd381..df7df90 100644 --- a/src/Views/Components/FileUpload.php +++ b/src/Views/Components/FileUpload.php @@ -10,33 +10,32 @@ class FileUpload extends File * @return void */ public function __construct( - string $name, - string $label = '', - string|array $type = 'document', + string $name, + string $label = '', + string|array $type = 'document', null|string|array $mimetypes = null, null|string|array $extensions = null, - ?int $maxSize = null, - string $collection = '', - string $conversion = '', - string $fileInputClass = '', - string $clearIcon = '', - string $downloadIcon = '', - string $uploadIcon = '', - $model = null, - $default = null, - bool $showHint = true, - bool $showErrors = true, - bool $showLabel = true, - bool $required = false, - bool $disabled = false, - bool $ignoreAccessor = false, - bool $inline = false, + ?int $maxSize = null, + string $collection = '', + string $conversion = '', + string $fileInputClass = '', + string $clearIcon = '', + string $downloadIcon = '', + string $uploadIcon = '', + $model = null, + $default = null, + bool $showHint = true, + bool $showErrors = true, + bool $showLabel = true, + bool $required = false, + bool $disabled = false, + bool $ignoreAccessor = false, + bool $inline = false, string $inlineLabelClass = '', string $inlineInputClass = '', bool $showJsErrors = false, - string $framework = '' - ) - { + string $framework = '' + ) { parent::__construct( name: $name, label: $label, diff --git a/src/Views/Components/FilterSubmit.php b/src/Views/Components/FilterSubmit.php index 15cdfab..8200313 100644 --- a/src/Views/Components/FilterSubmit.php +++ b/src/Views/Components/FilterSubmit.php @@ -5,7 +5,9 @@ class FilterSubmit extends Component { protected string $view = 'filter-submit'; + public string $cancelUrl; + public bool|array $export; /** @@ -23,5 +25,4 @@ public function __construct( $this->export = is_string($export) ? [$export] : $export; $this->cancelUrl = $cancelUrl; } - } diff --git a/src/Views/Components/Form.php b/src/Views/Components/Form.php index 37d28c8..cdafeb1 100644 --- a/src/Views/Components/Form.php +++ b/src/Views/Components/Form.php @@ -35,6 +35,10 @@ public function __construct( string $method = 'POST', $model = null, bool $files = false, + public bool $summary = false, + public string $summaryTitle = '', + public bool $focusOnError = true, + public string $errorBag = 'default', string $framework = '' ) { parent::__construct($framework); diff --git a/src/Views/Components/FormGroup.php b/src/Views/Components/FormGroup.php index 1869d77..5c5a3bf 100644 --- a/src/Views/Components/FormGroup.php +++ b/src/Views/Components/FormGroup.php @@ -5,13 +5,21 @@ class FormGroup extends Component { protected string $view = 'form-group'; + public string $name; + public string $label; + public bool $required; + public bool $inline; + public bool $floating; + public bool $wrap; + public bool $showLabel; + public string $inlineInputClass; /** @@ -22,11 +30,11 @@ class FormGroup extends Component public function __construct( string $name = '', string $label = '', - bool $required = false, - bool $inline = false, - bool $floating = false, - bool $wrap = true, - bool $showLabel = true, + bool $required = false, + bool $inline = false, + bool $floating = false, + bool $wrap = true, + bool $showLabel = true, public string $inlineLabelClass = '', string $inlineInputClass = '', public bool $blankLabel = false, @@ -42,7 +50,7 @@ public function __construct( $this->wrap = $wrap; $this->showLabel = $showLabel; - $this->inlineInputClass = $inlineInputClass ?: $this->frameworkConfig('inline-input-class'); + $this->inlineInputClass = $inlineInputClass ?: $this->frameworkConfig('inline_input_class', ''); } public function label(): string diff --git a/src/Views/Components/FormOpen.php b/src/Views/Components/FormOpen.php index 271b915..74580ea 100644 --- a/src/Views/Components/FormOpen.php +++ b/src/Views/Components/FormOpen.php @@ -19,14 +19,14 @@ class FormOpen extends Form public function __construct( string $method = 'POST', $model = null, - bool $files = false, + bool $files = false, string $framework = '' ) { parent::__construct( $method, $model, $files, - $framework, + framework: $framework, ); } } diff --git a/src/Views/Components/FormattedValue.php b/src/Views/Components/FormattedValue.php new file mode 100644 index 0000000..99bd0d1 --- /dev/null +++ b/src/Views/Components/FormattedValue.php @@ -0,0 +1,32 @@ +value = ! is_null($value) ? $value : ($name ? $this->getBoundValue($model, $name) : ''); + } +} diff --git a/src/Views/Components/Hidden.php b/src/Views/Components/Hidden.php index fd94bcf..05a42aa 100644 --- a/src/Views/Components/Hidden.php +++ b/src/Views/Components/Hidden.php @@ -30,7 +30,7 @@ public function __construct( default: $default, showErrors: $showErrors, showLabel: $showLabel, - required:$required, + required: $required, inline: $inline, floating: $floating, showJsErrors: $showJsErrors, diff --git a/src/Views/Components/Icon.php b/src/Views/Components/Icon.php new file mode 100644 index 0000000..178b9c4 --- /dev/null +++ b/src/Views/Components/Icon.php @@ -0,0 +1,37 @@ + when given CSS classes. + * + * The name can be a bundled icon ("calendar"), a logical icon from forms.icons ("date"), + * or CSS classes ("fa-regular fa-calendar"). + */ + public function __construct( + public string $name, + string $framework = '', + ) { + parent::__construct($framework); + + $this->icon = Svg::has($name) ? $name : $this->getFrameworkIcon($name); + } + + public function isSvg(): bool + { + return Svg::has($this->icon); + } + + public function svg(): string + { + return Svg::get($this->icon) ?? ''; + } +} diff --git a/src/Views/Components/Image.php b/src/Views/Components/Image.php index d5ad83e..e03dd47 100644 --- a/src/Views/Components/Image.php +++ b/src/Views/Components/Image.php @@ -7,6 +7,7 @@ class Image extends File protected string $view = 'image'; public string $icon; + public float $aspectRatio; /** @@ -15,40 +16,39 @@ class Image extends File * @return void */ public function __construct( - string $name, - string $label = '', - string|array $type = 'image', + string $name, + string $label = '', + string|array $type = 'image', null|string|array $mimetypes = null, null|string|array $extensions = null, - ?int $maxSize = null, - string $collection = '', - string $conversion = '', - string $fileInputClass = '', - string $icon = '', - string $uploadIcon = '', - public int $width = 400, - public int $height = 400, - public bool $cover = false, - public bool $fullwidth = false, - public bool $maintainAspectRatio = true, - public bool $circle = false, - bool $upload = false, - ?float $aspectRatio = null, - $model = null, - $default = null, - bool $showHint = true, - bool $showErrors = true, - bool $showLabel = true, - bool $required = false, - bool $disabled = false, - bool $ignoreAccessor = false, - bool $inline = false, + ?int $maxSize = null, + string $collection = '', + string $conversion = '', + string $fileInputClass = '', + string $icon = '', + string $uploadIcon = '', + public int $width = 400, + public int $height = 400, + public bool $cover = false, + public bool $fullwidth = false, + public bool $maintainAspectRatio = true, + public bool $circle = false, + bool $upload = false, + ?float $aspectRatio = null, + $model = null, + $default = null, + bool $showHint = true, + bool $showErrors = true, + bool $showLabel = true, + bool $required = false, + bool $disabled = false, + bool $ignoreAccessor = false, + bool $inline = false, string $inlineLabelClass = '', string $inlineInputClass = '', bool $showJsErrors = false, - string $framework = '' - ) - { + string $framework = '' + ) { parent::__construct( $name, label: $label, @@ -76,7 +76,7 @@ public function __construct( framework: $framework ); - $this->icon = $icon ?: $this->getFrameworkIcon($this->frameworkConfig('image-icon')); + $this->icon = $icon ?: $this->getFrameworkIcon('image'); if ($this->circle) { $aspectRatio = 1; diff --git a/src/Views/Components/ImageUpload.php b/src/Views/Components/ImageUpload.php index 1689cc3..3e59126 100644 --- a/src/Views/Components/ImageUpload.php +++ b/src/Views/Components/ImageUpload.php @@ -10,39 +10,38 @@ class ImageUpload extends Image * @return void */ public function __construct( - string $name, - string $label = '', - string|array $type = 'image', + string $name, + string $label = '', + string|array $type = 'image', null|string|array $mimetypes = null, null|string|array $extensions = null, - ?int $maxSize = null, - string $collection = '', - string $conversion = '', - string $fileInputClass = '', - string $icon = '', - string $uploadIcon = '', - int $width = 400, - int $height = 400, - bool $cover = false, - bool $fullwidth = false, - bool $maintainAspectRatio = true, - bool $circle = false, - ?float $aspectRatio = null, - $model = null, - $default = null, - bool $showHint = true, - bool $showErrors = true, - bool $showLabel = true, - bool $required = false, - bool $disabled = false, - bool $ignoreAccessor = false, - bool $inline = false, + ?int $maxSize = null, + string $collection = '', + string $conversion = '', + string $fileInputClass = '', + string $icon = '', + string $uploadIcon = '', + int $width = 400, + int $height = 400, + bool $cover = false, + bool $fullwidth = false, + bool $maintainAspectRatio = true, + bool $circle = false, + ?float $aspectRatio = null, + $model = null, + $default = null, + bool $showHint = true, + bool $showErrors = true, + bool $showLabel = true, + bool $required = false, + bool $disabled = false, + bool $ignoreAccessor = false, + bool $inline = false, string $inlineLabelClass = '', string $inlineInputClass = '', bool $showJsErrors = false, - string $framework = '' - ) - { + string $framework = '' + ) { parent::__construct( name: $name, label: $label, diff --git a/src/Views/Components/Input.php b/src/Views/Components/Input.php index d9da78a..6da3b58 100644 --- a/src/Views/Components/Input.php +++ b/src/Views/Components/Input.php @@ -7,19 +7,29 @@ class Input extends Component { - use HandlesValidationErrors; use HandlesDefaultAndOldValue; + use HandlesValidationErrors; protected string $view = 'input'; + public string $name; + public string $label; + public string $type; + public string $placeholder; + public bool $showPlaceholder; + public bool $required; + public bool $inline; + public bool $floating; + public bool $showLabel; + public $value; /** @@ -33,13 +43,13 @@ public function __construct( string $type = 'text', $model = null, $default = null, - bool $showErrors = true, - bool $showLabel = true, + bool $showErrors = true, + bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, - bool $required = false, - bool $inline = false, - bool $floating = false, + bool $showPlaceholder = false, + bool $required = false, + bool $inline = false, + bool $floating = false, public string $inlineLabelClass = '', public string $inlineInputClass = '', public bool $showJsErrors = false, diff --git a/src/Views/Components/JsErrors.php b/src/Views/Components/JsErrors.php index a25d241..967d9c7 100644 --- a/src/Views/Components/JsErrors.php +++ b/src/Views/Components/JsErrors.php @@ -7,6 +7,7 @@ class JsErrors extends Component { protected string $view = 'js-errors'; + public string $name; /** diff --git a/src/Views/Components/Label.php b/src/Views/Components/Label.php index 8b80b5f..3942bb3 100644 --- a/src/Views/Components/Label.php +++ b/src/Views/Components/Label.php @@ -7,9 +7,13 @@ class Label extends Component protected string $view = 'label'; public string $label; + public bool $required; + public bool $inline; + public bool $floating; + public string $inlineLabelClass; /** @@ -36,7 +40,7 @@ public function __construct( $this->floating = $floating; - $this->inlineLabelClass = $inlineLabelClass ?: $this->frameworkConfig('inline-label-class'); + $this->inlineLabelClass = $inlineLabelClass ?: $this->frameworkConfig('inline_label_class', ''); } public function label(): string diff --git a/src/Views/Components/Latitude.php b/src/Views/Components/Latitude.php index 0f67957..4c15055 100644 --- a/src/Views/Components/Latitude.php +++ b/src/Views/Components/Latitude.php @@ -2,8 +2,6 @@ namespace Javaabu\Forms\Views\Components; -use Illuminate\View\ComponentAttributeBag; - class Latitude extends Input { /** @@ -19,7 +17,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -38,7 +36,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, @@ -53,7 +51,7 @@ public function getDefaultAttributes(): array return [ 'step' => '0.000001', 'min' => -90, - 'max' => 90 + 'max' => 90, ]; } } diff --git a/src/Views/Components/LinkButton.php b/src/Views/Components/LinkButton.php index 1ccb361..96b30cf 100644 --- a/src/Views/Components/LinkButton.php +++ b/src/Views/Components/LinkButton.php @@ -6,22 +6,25 @@ class LinkButton extends Component { protected string $view = 'link-button'; - public string $url; - public string $color; - /** - * Create a new component instance. - * - * @return void + * @param string $color Bootstrap colour name: primary, danger, outline-secondary, link, ... + * @param string $size sm, md (default), lg or icon + * @param bool $disabled Render without an href and mark it aria-disabled */ public function __construct( - string $url = '', - string $color = 'primary', - string $framework = '' + public string $url = '', + public string $color = 'primary', + public string $size = '', + public string $startIcon = '', + public string $endIcon = '', + public bool $disabled = false, + string $framework = '', ) { parent::__construct($framework); + } - $this->url = $url; - $this->color = $color; + public function frameworkSize(): ?string + { + return in_array($this->size, ['', 'md', 'default']) ? null : $this->size; } } diff --git a/src/Views/Components/Longitude.php b/src/Views/Components/Longitude.php index ee8c02f..60adc27 100644 --- a/src/Views/Components/Longitude.php +++ b/src/Views/Components/Longitude.php @@ -2,8 +2,6 @@ namespace Javaabu\Forms\Views\Components; -use Illuminate\View\ComponentAttributeBag; - class Longitude extends Input { /** @@ -19,7 +17,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -38,7 +36,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, @@ -53,7 +51,7 @@ public function getDefaultAttributes(): array return [ 'step' => '0.000001', 'min' => -180, - 'max' => 180 + 'max' => 180, ]; } } diff --git a/src/Views/Components/MapInput.php b/src/Views/Components/MapInput.php index 6bf6329..755837b 100644 --- a/src/Views/Components/MapInput.php +++ b/src/Views/Components/MapInput.php @@ -3,6 +3,7 @@ namespace Javaabu\Forms\Views\Components; use Illuminate\Support\Str; +use Javaabu\Forms\Support\Settings; use MatanYadaev\EloquentSpatial\Objects\Point; use MatanYadaev\EloquentSpatial\Objects\Polygon; @@ -11,6 +12,7 @@ class MapInput extends Input protected string $view = 'map-input'; public float $defaultMapCenterLat; + public float $defaultMapCenterLng; /** @@ -30,7 +32,7 @@ public function __construct( public bool $enablePolygon = false, public bool $hideInputs = false, public int $radiusPrecision = 0, - public int $maxRadius = 6371000, + public int $maxRadius = 6371000, public string $radiusUnit = 'm', public ?float $lat = null, public ?float $lng = null, @@ -60,7 +62,7 @@ public function __construct( model: $model, showErrors: $showErrors, showLabel: $showLabel, - required:$required, + required: $required, inline: $inline, floating: false, inlineLabelClass: $inlineLabelClass, @@ -69,8 +71,8 @@ public function __construct( framework: $framework ); - $this->defaultMapCenterLat = is_null($defaultMapCenterLat) ? (float) get_setting('default_lat') : $defaultMapCenterLat; - $this->defaultMapCenterLng = is_null($defaultMapCenterLng) ? (float) get_setting('default_lng') : $defaultMapCenterLng; + $this->defaultMapCenterLat = is_null($defaultMapCenterLat) ? (float) Settings::get('default_lat', 4.1755) : $defaultMapCenterLat; + $this->defaultMapCenterLng = is_null($defaultMapCenterLng) ? (float) Settings::get('default_lng', 73.5093) : $defaultMapCenterLng; if (is_null($this->lat)) { $this->setLat($this->latName, $model, $this->defaultLat); diff --git a/src/Views/Components/Modal.php b/src/Views/Components/Modal.php index ff5f2fa..33bfd1f 100644 --- a/src/Views/Components/Modal.php +++ b/src/Views/Components/Modal.php @@ -7,6 +7,7 @@ class Modal extends Component protected string $view = 'modal'; public string $title; + public ?string $modalSizeClass = null; /** @@ -17,7 +18,9 @@ class Modal extends Component public function __construct( string $id = '', string $title = '', - string $modalSizeClass = null, + ?string $modalSizeClass = null, + public string $size = 'lg', + public string $description = '', string $framework = '' ) { parent::__construct($framework); diff --git a/src/Views/Components/NavTabs.php b/src/Views/Components/NavTabs.php index a0b83bf..19a05d4 100644 --- a/src/Views/Components/NavTabs.php +++ b/src/Views/Components/NavTabs.php @@ -17,7 +17,7 @@ class NavTabs extends Component */ public function __construct( array|Collection $tabs = [], - string $framework = '' + string $framework = '' ) { parent::__construct($framework); diff --git a/src/Views/Components/NoItems.php b/src/Views/Components/NoItems.php index 25dae45..9ac302f 100644 --- a/src/Views/Components/NoItems.php +++ b/src/Views/Components/NoItems.php @@ -7,11 +7,17 @@ class NoItems extends Component protected string $view = 'no-items'; public null|string|object $model; + public string $createAction; + public ?string $modelType = null; + public string $icon; + public bool $showCreate; + public string $title; + public string $message; /** @@ -20,32 +26,31 @@ class NoItems extends Component * @return void */ public function __construct( - string $createAction = '#', + string $createAction = '#', null|string|object $model = null, - string $icon = '', - ?string $modelType = null, - ?bool $showCreate = null, - ?string $title = null, - ?string $message = null, - string $framework = '', - ) - { + string $icon = '', + ?string $modelType = null, + ?bool $showCreate = null, + ?string $title = null, + ?string $message = null, + string $framework = '', + ) { parent::__construct($framework); $this->model = $model; $this->createAction = $createAction; $this->modelType = $modelType; - $this->icon = $icon ?: $this->getFrameworkIcon($this->frameworkConfig('no-items-icon')); + $this->icon = $icon ?: $this->getFrameworkIcon('no_items'); $this->showCreate = is_null($showCreate) ? ($this->model && auth()->user() ? auth()->user()->can('create', $this->model) : false) : $showCreate; $this->title = is_null($title) - ? __('It\'s a bit lonely here.') + ? __('forms::strings.no_items_title') : $title; $this->message = is_null($message) - ? __('Let\'s create some new :model_type.', ['model_type' => $this->modelType ?? __('items')]) + ? __('forms::strings.no_items_message', ['model_type' => $this->modelType ?? __('forms::strings.items')]) : $message; } diff --git a/src/Views/Components/Number.php b/src/Views/Components/Number.php index cef7298..b1bb9c0 100644 --- a/src/Views/Components/Number.php +++ b/src/Views/Components/Number.php @@ -17,7 +17,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -36,7 +36,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, diff --git a/src/Views/Components/Pagination.php b/src/Views/Components/Pagination.php new file mode 100644 index 0000000..8a1e90b --- /dev/null +++ b/src/Views/Components/Pagination.php @@ -0,0 +1,25 @@ +framework}.pagination"; + } +} diff --git a/src/Views/Components/Password.php b/src/Views/Components/Password.php index c702bd7..65cb54b 100644 --- a/src/Views/Components/Password.php +++ b/src/Views/Components/Password.php @@ -17,7 +17,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -36,7 +36,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, diff --git a/src/Views/Components/PerPage.php b/src/Views/Components/PerPage.php index 4fb6b0c..a0760b2 100644 --- a/src/Views/Components/PerPage.php +++ b/src/Views/Components/PerPage.php @@ -8,18 +8,26 @@ class PerPage extends Component public array $amounts; + public string $name = 'per_page'; + /** * Create a new component instance. * * @return void */ public function __construct( - array $amounts = [10 => 10, 20 => 20, 50 => 50, 100 => 100, 500 => 500], + array $amounts = [10 => 10, 20 => 20, 50 => 50, 100 => 100, 500 => 500], + public string $label = '', + public bool $showLabel = false, + public int|string|null $perPage = null, string $framework = '', - ) - { + ) { $this->amounts = $amounts; parent::__construct($framework); } + public function label(): string + { + return $this->label ?: trans('forms::strings.per_page'); + } } diff --git a/src/Views/Components/Radio.php b/src/Views/Components/Radio.php index dd749aa..07511ef 100644 --- a/src/Views/Components/Radio.php +++ b/src/Views/Components/Radio.php @@ -2,6 +2,7 @@ namespace Javaabu\Forms\Views\Components; +use BackedEnum; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -41,7 +42,7 @@ public function __construct( default: $default, showErrors: $showErrors, showLabel: $showLabel, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, @@ -66,12 +67,32 @@ public function __construct( } if (is_array($boundValue)) { - $this->checked = in_array($value, $boundValue); + $this->checked = in_array($value instanceof BackedEnum ? $value->value : $value, array_map(fn ($item) => $item instanceof BackedEnum ? $item->value : $item, $boundValue)); return; } - $this->checked = is_null($boundValue) ? $default : $boundValue; + $this->checked = is_null($boundValue) ? (bool) $default : $this->matchesValue($boundValue, $value); } } + + /** + * A radio is checked when the bound value is its value (backed enums by their value). + */ + protected function matchesValue(mixed $bound, mixed $value): bool + { + if ($bound instanceof BackedEnum) { + $bound = $bound->value; + } + + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (is_bool($bound)) { + return $bound === (bool) $value; + } + + return (string) $bound === (string) $value; + } } diff --git a/src/Views/Components/SearchForm.php b/src/Views/Components/SearchForm.php index b5cd48e..c1d8efb 100644 --- a/src/Views/Components/SearchForm.php +++ b/src/Views/Components/SearchForm.php @@ -29,8 +29,8 @@ public function __construct( $model = request()->query(); } - parent::__construct($method, $model, $files, $framework); + parent::__construct($method, $model, $files, framework: $framework); - $this->icon = $icon ?: $this->getFrameworkIcon($this->frameworkConfig('search-icon')); + $this->icon = $icon ?: $this->getFrameworkIcon('search'); } } diff --git a/src/Views/Components/Select.php b/src/Views/Components/Select.php index 8cfe724..2d7a961 100644 --- a/src/Views/Components/Select.php +++ b/src/Views/Components/Select.php @@ -9,32 +9,49 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Javaabu\Forms\Assets\Assets; use Javaabu\Forms\Support\HandlesDefaultAndOldValue; use Javaabu\Forms\Support\HandlesValidationErrors; class Select extends Component { - use HandlesValidationErrors; use HandlesDefaultAndOldValue; + use HandlesValidationErrors; protected string $view = 'select'; public string $name; + public string $label; + public $options; + public $selectedKey; + public bool $multiple; + public bool $required; + public bool $inline; + public bool $floating; + public string $placeholder; + public bool $showPlaceholder; + public bool $isSelect2; + public string $nameField; + public string $idField; + public bool $showLabel; + public string $syncFieldName; + public bool $isAjax; + public bool $tags; /** @@ -49,16 +66,16 @@ public function __construct( $options = [], $model = null, $default = null, - bool $multiple = false, - bool $relation = false, - bool $showErrors = true, - bool $showLabel = true, - bool $showPlaceholder = false, - bool $required = false, - bool $inline = false, - bool $floating = false, - bool $isSelect2 = false, - bool $isAjax = false, + bool $multiple = false, + bool $relation = false, + bool $showErrors = true, + bool $showLabel = true, + bool $showPlaceholder = false, + bool $required = false, + bool $inline = false, + bool $floating = false, + bool $isSelect2 = false, + bool $isAjax = false, public bool $disabled = false, public bool $excludeSyncField = false, string $syncFieldName = '', @@ -155,8 +172,8 @@ public function getOptionsFromQueryBuilder(BuilderContract $query): array if ($is_accessor || $this->isJavaabuTranslatableModel($model)) { return $query->get() - ->pluck($name_field, $id_field) - ->all(); + ->pluck($name_field, $id_field) + ->all(); } return $query->pluck($name_field, $id_field)->all(); @@ -179,6 +196,12 @@ public function nothingSelected(): bool public function shouldShowSyncField(): bool { - return (!$this->excludeSyncField) && $this->multiple && (!$this->disabled); + return (! $this->excludeSyncField) && $this->multiple && (! $this->disabled); + } + + /** @return array */ + public function assets(): array + { + return $this->isSelect2 ? [Assets::CORE, Assets::SELECT] : [Assets::CORE]; } } diff --git a/src/Views/Components/Select2.php b/src/Views/Components/Select2.php index 1cd6656..0c90fbd 100644 --- a/src/Views/Components/Select2.php +++ b/src/Views/Components/Select2.php @@ -5,15 +5,25 @@ class Select2 extends Select { public bool $allowClear; + public bool $isFirst; + public bool $hideSearch; + public string $child; + public string $ajaxChild; + public string $ajaxUrl; + public string $selectedUrl; + public string $filterField; + public string $fallback; + public string $parentModal; + /** * Create a new component instance. * @@ -26,17 +36,17 @@ public function __construct( $options = [], $model = null, $default = null, - bool $multiple = false, - bool $relation = false, - bool $showErrors = true, - bool $showLabel = true, - bool $showPlaceholder = false, - bool $required = false, - bool $isAjax = false, - bool $isFirst = false, - bool $tags = false, - bool $hideSearch = false, - bool $allowClear = true, + bool $multiple = false, + bool $relation = false, + bool $showErrors = true, + bool $showLabel = true, + bool $showPlaceholder = false, + bool $required = false, + bool $isAjax = false, + bool $isFirst = false, + bool $tags = false, + bool $hideSearch = false, + bool $allowClear = true, public bool $isIconSelect = false, string $child = '', string $ajaxChild = '', @@ -57,8 +67,13 @@ public function __construct( string $inlineInputClass = '', string $formGroupClass = '', bool $showJsErrors = false, + ?bool $clearable = null, + public ?int $minChars = null, + public string $parent = '', string $framework = '' ) { + $allowClear = $clearable ?? $allowClear; + if ($allowClear && empty($placeholder)) { $placeholder = $this->getNothingSelectedText(); } diff --git a/src/Views/Components/Submit.php b/src/Views/Components/Submit.php index bcdcc1f..f805a75 100644 --- a/src/Views/Components/Submit.php +++ b/src/Views/Components/Submit.php @@ -4,15 +4,15 @@ class Submit extends Button { - /** - * Create a new component instance. - * - * @return void - */ public function __construct( string $color = 'primary', - string $framework = '' + string $size = '', + string $startIcon = '', + string $endIcon = '', + bool $animate = true, + bool $loading = false, + string $framework = '', ) { - parent::__construct('submit', $color, true, $framework); + parent::__construct('submit', $color, $animate, $size, $startIcon, $endIcon, $loading, $framework); } } diff --git a/src/Views/Components/Table.php b/src/Views/Components/Table.php index 2207f5b..fef0c2f 100644 --- a/src/Views/Components/Table.php +++ b/src/Views/Components/Table.php @@ -7,13 +7,17 @@ class Table extends Component protected string $view = 'table'; public bool $striped; + public bool $no_bulk; public string $model; + public string $table_class; + public ?string $filter_id; public bool $no_pagination; + public bool $noCheckbox; /** @@ -23,11 +27,11 @@ class Table extends Component */ public function __construct( string $framework = '', - bool $striped = false, - bool $noBulk = false, + bool $striped = false, + bool $noBulk = false, string $model = '', string $tableClass = '', - string $filterId = null, + ?string $filterId = null, bool $noPagination = false, bool $noCheckbox = false, ) { diff --git a/src/Views/Components/Table/Cell.php b/src/Views/Components/Table/Cell.php index bf0fc69..49ab851 100644 --- a/src/Views/Components/Table/Cell.php +++ b/src/Views/Components/Table/Cell.php @@ -8,14 +8,19 @@ class Cell extends Component { - use HandlesBoundValues; use FormatsValues; + use HandlesBoundValues; protected string $view = 'table.cell'; + public bool $multiline; + public bool $showLabel; + public string $label; + public string $name; + public $value; /** diff --git a/src/Views/Components/Table/EmptyRow.php b/src/Views/Components/Table/EmptyRow.php index c877cee..baa0c82 100644 --- a/src/Views/Components/Table/EmptyRow.php +++ b/src/Views/Components/Table/EmptyRow.php @@ -9,6 +9,7 @@ class EmptyRow extends Component protected string $view = 'table.empty-row'; public int $columns; + public bool $noCheckbox; /** diff --git a/src/Views/Components/Table/Heading.php b/src/Views/Components/Table/Heading.php index a5dd003..8408492 100644 --- a/src/Views/Components/Table/Heading.php +++ b/src/Views/Components/Table/Heading.php @@ -10,8 +10,11 @@ class Heading extends Component protected string $view = 'table.heading'; public int $colspan; + public string $sortable; + public string $label; + public string $name; /** @@ -21,7 +24,7 @@ class Heading extends Component */ public function __construct( string $label = '', - int $colspan = 1, + int $colspan = 1, string $sortable = '', string $name = '', string $framework = '', @@ -35,9 +38,6 @@ public function __construct( /** * Adds sorting class - * - * @param string $classes - * @return string */ public function addSortClass(string $classes = ''): string { @@ -52,4 +52,16 @@ public function addSortClass(string $classes = ''): string return $sorting_class; } + + /** + * The logical icon (forms.icons) for the current sort state of this column. + */ + public function sortIcon(): string + { + if (Request::get('orderby') != $this->sortable) { + return 'sort'; + } + + return strtolower(Request::get('order', 'ASC')) === 'desc' ? 'sort_desc' : 'sort_asc'; + } } diff --git a/src/Views/Components/Table/Row.php b/src/Views/Components/Table/Row.php index ff1affb..5cdf300 100644 --- a/src/Views/Components/Table/Row.php +++ b/src/Views/Components/Table/Row.php @@ -10,10 +10,15 @@ class Row extends Component { use HandlesBoundValues; + protected string $view = 'table.row'; + public string $rowId; + public string $name; + public string $modelId; + public bool $noCheckbox; /** diff --git a/src/Views/Components/Tabs.php b/src/Views/Components/Tabs.php index 33e648a..b3e9175 100644 --- a/src/Views/Components/Tabs.php +++ b/src/Views/Components/Tabs.php @@ -10,6 +10,7 @@ class Tabs extends Component protected string $view = 'tabs'; public Collection $tabs; + public string $active; /** @@ -19,8 +20,8 @@ class Tabs extends Component */ public function __construct( array|Collection $tabs = [], - string $active = '', - string $framework = '' + string $active = '', + string $framework = '' ) { parent::__construct($framework); diff --git a/src/Views/Components/Tel.php b/src/Views/Components/Tel.php index 9fb9e7c..2599185 100644 --- a/src/Views/Components/Tel.php +++ b/src/Views/Components/Tel.php @@ -32,7 +32,7 @@ public function __construct( default: $default, showErrors: $showErrors, showLabel: $showLabel, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, diff --git a/src/Views/Components/Text.php b/src/Views/Components/Text.php index c47ab6e..af232e0 100644 --- a/src/Views/Components/Text.php +++ b/src/Views/Components/Text.php @@ -17,7 +17,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -36,7 +36,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, diff --git a/src/Views/Components/TextEntry.php b/src/Views/Components/TextEntry.php index 427b1ed..432988f 100644 --- a/src/Views/Components/TextEntry.php +++ b/src/Views/Components/TextEntry.php @@ -7,17 +7,25 @@ class TextEntry extends Component { - use HandlesBoundValues; use FormatsValues; + use HandlesBoundValues; protected string $view = 'text-entry'; + public bool $inline; + public bool $multiline; + public string $label; + public string $name; + public bool $showLabel; + public $value; + public string $inlineEntryLabelClass; + public string $inlineEntryClass; /** @@ -47,7 +55,7 @@ public function __construct( $this->multiline = $multiline; $this->value = ! is_null($value) ? $value : ($name ? $this->getBoundValue($model, $name) : ''); - $this->inlineEntryLabelClass = $inlineEntryLabelClass ?: $this->frameworkConfig('inline-entry-label-class'); - $this->inlineEntryClass = $inlineEntryClass ?: $this->frameworkConfig('inline-entry-class'); + $this->inlineEntryLabelClass = $inlineEntryLabelClass ?: $this->frameworkConfig('inline_entry_label_class', ''); + $this->inlineEntryClass = $inlineEntryClass ?: $this->frameworkConfig('inline_entry_class', ''); } } diff --git a/src/Views/Components/Textarea.php b/src/Views/Components/Textarea.php index edd8dfe..274a236 100644 --- a/src/Views/Components/Textarea.php +++ b/src/Views/Components/Textarea.php @@ -2,6 +2,8 @@ namespace Javaabu\Forms\Views\Components; +use Javaabu\Forms\Assets\Assets; + class Textarea extends Input { protected string $view = 'textarea'; @@ -23,7 +25,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, public bool $wysiwyg = false, bool $required = false, bool $inline = false, @@ -43,7 +45,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, @@ -58,4 +60,10 @@ public function __construct( $this->value = $value; } } + + /** @return array */ + public function assets(): array + { + return $this->wysiwyg ? [Assets::CORE, Assets::WYSIWYG] : [Assets::CORE]; + } } diff --git a/src/Views/Components/Time.php b/src/Views/Components/Time.php index 478b2fb..194a2e6 100644 --- a/src/Views/Components/Time.php +++ b/src/Views/Components/Time.php @@ -14,6 +14,7 @@ public function __construct( string $label = '', $model = null, $default = null, + string $dateFormat = '', string $icon = '', string $clearIcon = '', string $clearBtnClass = '', @@ -21,7 +22,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -36,6 +37,7 @@ public function __construct( type: 'time', model: $model, default: $default, + dateFormat: $dateFormat, icon: $icon, clearIcon: $clearIcon, clearBtnClass: $clearBtnClass, @@ -44,7 +46,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, @@ -54,7 +56,7 @@ public function __construct( ); if (! $icon) { - $this->icon = $this->getFrameworkIcon($this->frameworkConfig('time-icon')); + $this->icon = $this->getFrameworkIcon('time'); } } @@ -62,4 +64,9 @@ public function datePickerClass(): string { return 'time-picker'; } + + public function defaultDateFormat(): string + { + return 'H:i'; + } } diff --git a/src/Views/Components/Url.php b/src/Views/Components/Url.php index b9956dc..9ffa5ab 100644 --- a/src/Views/Components/Url.php +++ b/src/Views/Components/Url.php @@ -17,7 +17,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, @@ -36,7 +36,7 @@ public function __construct( showLabel: $showLabel, placeholder: $placeholder, showPlaceholder: $showPlaceholder, - required:$required, + required: $required, inline: $inline, floating: $floating, inlineLabelClass: $inlineLabelClass, diff --git a/src/Views/Components/Wysiwyg.php b/src/Views/Components/Wysiwyg.php index 7d49f12..8f353e2 100644 --- a/src/Views/Components/Wysiwyg.php +++ b/src/Views/Components/Wysiwyg.php @@ -19,7 +19,7 @@ public function __construct( bool $showErrors = true, bool $showLabel = true, string $placeholder = '', - bool $showPlaceholder = false, + bool $showPlaceholder = false, bool $required = false, bool $inline = false, bool $floating = false, diff --git a/tests/Feature/AccordionTest.php b/tests/Feature/AccordionTest.php index bf32fb8..e795c8f 100644 --- a/tests/Feature/AccordionTest.php +++ b/tests/Feature/AccordionTest.php @@ -3,257 +3,188 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class AccordionTest extends TestCase { - public function test_it_can_generate_bootstrap_5_accordion_header() + #[Test] + public function it_can_generate_bootstrap_6_accordion_header() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('accordion-header'); $this->visit('/accordion-header') - ->seeElement('h2.accordion-header#item-1') + ->seeElement('summary.accordion-header#item-1') ->within('.accordion-header#item-1', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#collapseOne"][aria-expanded="true"][aria-controls="collapseOne"]') - ->seeInElement('.accordion-button', 'Accordion Item #1'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #1'); }) - ->seeElement('h2.accordion-header#item-2') + ->seeElement('summary.accordion-header#item-2') ->within('.accordion-header#item-2', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#collapseTwo"][aria-expanded="false"][aria-controls="collapseTwo"]') - ->seeInElement('.accordion-button', 'Accordion Item #2'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #2'); }); } - public function test_it_can_generate_material_admin_26_accordion_header() + #[Test] + public function it_can_generate_bootstrap_6_accordion_collapse() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('accordion-header'); - - $this->visit('/accordion-header') - ->seeElement('div.accordion__title#item-1[data-toggle="collapse"][data-target="#collapseOne"][aria-expanded="true"][aria-controls="collapseOne"]') - ->seeInElement('.accordion__title#item-1', 'Accordion Item #1') - ->seeElement('div.accordion__title#item-2[data-toggle="collapse"][data-target="#collapseTwo"][aria-expanded="false"][aria-controls="collapseTwo"]') - ->seeInElement('.accordion__title#item-2', 'Accordion Item #2'); - } - - public function test_it_can_generate_bootstrap_5_accordion_collapse() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('accordion-collapse'); - - $this->visit('/accordion-collapse') - ->seeElement('div.accordion-collapse#item-1.collapse.show[data-bs-parent="#accordionExample"]') - ->seeInElement('.accordion-collapse#item-1', 'Accordion Item #1') - - ->seeElement('div.accordion-collapse#item-2.collapse[data-bs-parent="#accordionExample"]') - ->within('.accordion-collapse#item-2', function () { - $this->seeElement('strong') - ->seeInElement('strong', 'Accordion Item #2'); - }); - - } - - public function test_it_can_generate_material_admin_26_accordion_collapse() - { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('accordion-collapse'); $this->visit('/accordion-collapse') - ->seeElement('div.collapse#item-1.collapse.show[data-parent="#accordionExample"]') - ->seeInElement('.collapse#item-1', 'Accordion Item #1') + ->seeElement('div.accordion-body#item-1[data-accordion="accordionExample"]') + ->seeInElement('.accordion-body#item-1', 'Accordion Item #1') - ->seeElement('div.collapse#item-2.collapse[data-parent="#accordionExample"]') - ->within('.collapse#item-2', function () { + ->seeElement('div.accordion-body#item-2[data-accordion="accordionExample"]') + ->within('.accordion-body#item-2', function () { $this->seeElement('strong') ->seeInElement('strong', 'Accordion Item #2'); }); } - public function test_it_can_generate_bootstrap_5_accordion_item() + #[Test] + public function it_can_generate_bootstrap_6_accordion_item() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('accordion-item'); $this->visit('/accordion-item') - ->seeElement('div.accordion-item#item-1') + ->seeElement('details.accordion-item#item-1[open][name="accordionExample"]') + ->seeElement('details.accordion-item#item-2[name="accordionExample"]') + ->dontSeeElement('details.accordion-item#item-2[open]') + ->seeElement('summary.accordion-header[aria-controls="collapseOne"]') + ->seeElement('details.accordion-item#item-1') ->within('.accordion-item#item-1', function () { - $this->seeElement('h2.accordion-header') + $this->seeElement('summary.accordion-header') ->within('.accordion-header', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#collapseOne"][aria-expanded="true"][aria-controls="collapseOne"]') - ->seeInElement('.accordion-button', 'Accordion Item #1'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #1'); }); - $this->seeElement('div.accordion-collapse#collapseOne.collapse.show[data-bs-parent="#accordionExample"]') - ->seeInElement('.accordion-collapse#collapseOne', 'First item Body'); + $this->seeElement('div.accordion-body#collapseOne[data-accordion="accordionExample"]') + ->seeInElement('.accordion-body#collapseOne', 'First item Body'); }) - ->seeElement('div.accordion-item#item-2') + ->seeElement('details.accordion-item#item-2') ->within('.accordion-item#item-2', function () { - $this->seeElement('h2.accordion-header') + $this->seeElement('summary.accordion-header') ->within('.accordion-header', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#collapseTwo"][aria-expanded="false"][aria-controls="collapseTwo"]') - ->seeInElement('.accordion-button', 'Accordion Item #2'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #2'); }); - $this->seeElement('div.accordion-collapse#collapseTwo.collapse[data-bs-parent="#accordionExample"]') - ->seeInElement('.accordion-collapse#collapseTwo', 'Second item Body'); + $this->seeElement('div.accordion-body#collapseTwo[data-accordion="accordionExample"]') + ->seeInElement('.accordion-body#collapseTwo', 'Second item Body'); }); } - public function test_it_can_generate_material_admin_26_accordion_item() + #[Test] + public function it_can_generate_bootstrap_6_accordion_using_named_items() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('accordion-item'); - - $this->visit('/accordion-item') - ->seeElement('div.accordion__item#item-1') - ->within('.accordion__item#item-1', function () { - $this->seeElement('div.accordion__title[data-toggle="collapse"][data-target="#collapseOne"][aria-expanded="true"][aria-controls="collapseOne"]') - ->seeInElement('.accordion__title', 'Accordion Item #1'); - - $this->seeInElement('div.collapse#collapseOne.collapse.show[data-parent="#accordionExample"]', 'First item Body'); - }) - - ->seeElement('div.accordion__item#item-2') - ->within('.accordion__item#item-2', function () { - $this->seeElement('div.accordion__title[data-toggle="collapse"][data-target="#collapseTwo"][aria-expanded="false"][aria-controls="collapseTwo"]') - ->seeInElement('.accordion__title', 'Accordion Item #2'); - - $this->seeInElement('div.collapse#collapseTwo.collapse[data-parent="#accordionExample"]', 'Second item Body'); - }); - - } - - public function test_it_can_generate_bootstrap_5_accordion_using_named_items() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('accordion'); $this->visit('/accordion') ->seeElement('div.accordion#accordionExample') ->within('.accordion#accordionExample', function () { - $this->seeElement('div.accordion-item#item-1') + $this->seeElement('details.accordion-item#item-1') ->within('.accordion-item#item-1', function () { - $this->seeElement('h2.accordion-header') + $this->seeElement('summary.accordion-header') ->within('.accordion-header', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#collapseOne"][aria-expanded="true"][aria-controls="collapseOne"]') - ->seeInElement('.accordion-button', 'Accordion Item #1'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #1'); }); - $this->seeElement('div.accordion-collapse#collapseOne.collapse.show[data-bs-parent="#accordionExample"]') - ->seeInElement('.accordion-collapse#collapseOne', 'First item Body'); + $this->seeElement('div.accordion-body#collapseOne[data-accordion="accordionExample"]') + ->seeInElement('.accordion-body#collapseOne', 'First item Body'); }) - ->seeElement('div.accordion-item#item-2') + ->seeElement('details.accordion-item#item-2') ->within('.accordion-item#item-2', function () { - $this->seeElement('h2.accordion-header') + $this->seeElement('summary.accordion-header') ->within('.accordion-header', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#collapseTwo"][aria-expanded="false"][aria-controls="collapseTwo"]') - ->seeInElement('.accordion-button', 'Accordion Item #2'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #2'); }); - $this->seeElement('div.accordion-collapse#collapseTwo.collapse[data-bs-parent="#accordionExample"]') - ->seeInElement('.accordion-collapse#collapseTwo', 'Second item Body'); - }); - }); - - } - - public function test_it_can_generate_material_admin_accordion_using_named_items() - { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('accordion'); - - $this->visit('/accordion') - ->seeElement('div.accordion#accordionExample[role="tablist"]') - ->within('.accordion#accordionExample', function () { - $this->seeElement('div.accordion__item#item-1') - ->within('.accordion__item#item-1', function () { - $this->seeElement('div.accordion__title[data-toggle="collapse"][data-target="#collapseOne"][aria-expanded="true"][aria-controls="collapseOne"]') - ->seeInElement('.accordion__title', 'Accordion Item #1'); - - $this->seeInElement('div.collapse#collapseOne.collapse.show[data-parent="#accordionExample"]', 'First item Body'); - }) - - ->seeElement('div.accordion__item#item-2') - ->within('.accordion__item#item-2', function () { - $this->seeElement('div.accordion__title[data-toggle="collapse"][data-target="#collapseTwo"][aria-expanded="false"][aria-controls="collapseTwo"]') - ->seeInElement('.accordion__title', 'Accordion Item #2'); - - $this->seeInElement('div.collapse#collapseTwo.collapse[data-parent="#accordionExample"]', 'Second item Body'); + $this->seeElement('div.accordion-body#collapseTwo[data-accordion="accordionExample"]') + ->seeInElement('.accordion-body#collapseTwo', 'Second item Body'); }); }); } - public function test_it_can_generate_bootstrap_5_accordion_using_unnamed_items() + #[Test] + public function it_can_generate_bootstrap_6_accordion_using_unnamed_items() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('accordion-unnamed'); $this->visit('/accordion-unnamed') ->seeElement('div.accordion#accordionExample') ->within('.accordion#accordionExample', function () { - $this->seeElement('div.accordion-item#item-1') + $this->seeElement('details.accordion-item#item-1') ->within('.accordion-item#item-1', function () { - $this->seeElement('h2.accordion-header') + $this->seeElement('summary.accordion-header') ->within('.accordion-header', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#accordionExample-item-0-collapse"][aria-expanded="true"][aria-controls="accordionExample-item-0-collapse"]') - ->seeInElement('.accordion-button', 'Accordion Item #1'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #1'); }); - $this->seeElement('div.accordion-collapse#accordionExample-item-0-collapse.collapse.show[data-bs-parent="#accordionExample"]') - ->seeInElement('.accordion-collapse#accordionExample-item-0-collapse', 'First item Body'); + $this->seeElement('div.accordion-body#accordionExample-item-0-collapse[data-accordion="accordionExample"]') + ->seeInElement('.accordion-body#accordionExample-item-0-collapse', 'First item Body'); }) - ->seeElement('div.accordion-item#item-2') + ->seeElement('details.accordion-item#item-2') ->within('.accordion-item#item-2', function () { - $this->seeElement('h2.accordion-header') + $this->seeElement('summary.accordion-header') ->within('.accordion-header', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#accordionExample-item-1-collapse"][aria-expanded="false"][aria-controls="accordionExample-item-1-collapse"]') - ->seeInElement('.accordion-button', 'Accordion Item #2'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #2'); }); - $this->seeElement('div.accordion-collapse#accordionExample-item-1-collapse.collapse[data-bs-parent="#accordionExample"]') - ->seeInElement('.accordion-collapse#accordionExample-item-1-collapse', 'Second item Body'); + $this->seeElement('div.accordion-body#accordionExample-item-1-collapse[data-accordion="accordionExample"]') + ->seeInElement('.accordion-body#accordionExample-item-1-collapse', 'Second item Body'); }); }); } - public function test_it_can_generate_bootstrap_5_accordion_using_slot() + #[Test] + public function it_can_generate_bootstrap_6_accordion_using_slot() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('accordion-slot'); $this->visit('/accordion-slot') ->seeElement('div.accordion#accordionExample') ->within('.accordion#accordionExample', function () { - $this->seeElement('div.accordion-item#item-1') + $this->seeElement('details.accordion-item#item-1') ->within('.accordion-item#item-1', function () { - $this->seeElement('h2.accordion-header') + $this->seeElement('summary.accordion-header') ->within('.accordion-header', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#collapseOne"][aria-expanded="true"][aria-controls="collapseOne"]') - ->seeInElement('.accordion-button', 'Accordion Item #1'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #1'); }); - $this->seeElement('div.accordion-collapse#collapseOne.collapse.show[data-bs-parent="#accordionExample"]') - ->seeInElement('.accordion-collapse#collapseOne', 'First item Body'); + $this->seeElement('div.accordion-body#collapseOne[data-accordion="accordionExample"]') + ->seeInElement('.accordion-body#collapseOne', 'First item Body'); }) - ->seeElement('div.accordion-item#item-2') + ->seeElement('details.accordion-item#item-2') ->within('.accordion-item#item-2', function () { - $this->seeElement('h2.accordion-header') + $this->seeElement('summary.accordion-header') ->within('.accordion-header', function () { - $this->seeElement('button.accordion-button[data-bs-toggle="collapse"][data-bs-target="#collapseTwo"][aria-expanded="false"][aria-controls="collapseTwo"]') - ->seeInElement('.accordion-button', 'Accordion Item #2'); + $this->seeElement('svg.accordion-icon') + ->see('Accordion Item #2'); }); - $this->seeElement('div.accordion-collapse#collapseTwo.collapse[data-bs-parent="#accordionExample"]') - ->seeInElement('.accordion-collapse#collapseTwo', 'Second item Body'); + $this->seeElement('div.accordion-body#collapseTwo[data-accordion="accordionExample"]') + ->seeInElement('.accordion-body#collapseTwo', 'Second item Body'); }); }); diff --git a/tests/Feature/AssetsTest.php b/tests/Feature/AssetsTest.php new file mode 100644 index 0000000..e83a205 --- /dev/null +++ b/tests/Feature/AssetsTest.php @@ -0,0 +1,128 @@ +manifest(); + + $this->assertTrue($manifest->hasPackage('forms')); + + foreach (['forms::forms', 'forms::select', 'forms::dates', 'forms::files', 'forms::wysiwyg', 'forms::onu'] as $id) { + $this->assertTrue($manifest->has($id), "{$id} is not registered"); + } + + $this->assertEquals('@javaabu/forms-blade', $manifest->packages()['forms']->importAlias()); + } + + #[Test] + public function it_loads_select_scripts_and_styles_after_the_core_script(): void + { + $ids = array_map(fn ($asset) => $asset->id(), $this->manifest()->resolve(['forms::select'])); + + $this->assertEquals(['forms::forms', 'forms::select-styles', 'forms::select'], $ids); + } + + #[Test] + public function components_require_only_the_assets_they_use(): void + { + $this->setFrameworkBootstrap6(); + + $this->renderBlade(''); + + $this->assertTrue($this->manifest()->isRequired('forms::forms')); + $this->assertFalse($this->manifest()->isRequired('forms::select')); + $this->assertFalse($this->manifest()->isRequired('forms::dates')); + + $this->renderBlade(''); + + foreach (['forms::select', 'forms::dates', 'forms::files', 'forms::wysiwyg'] as $id) { + $this->assertTrue($this->manifest()->isRequired($id), "{$id} was not required"); + } + } + + #[Test] + public function plain_selects_and_textareas_dont_load_their_enhanced_scripts(): void + { + $this->setFrameworkBootstrap6(); + + $this->renderBlade(''); + + $this->assertFalse($this->manifest()->isRequired('forms::select')); + $this->assertFalse($this->manifest()->isRequired('forms::wysiwyg')); + } + + #[Test] + public function onu_components_load_the_onu_stylesheet_unless_turned_off(): void + { + $this->setFrameworkOnu(); + + $this->renderBlade(''); + + $this->assertTrue($this->manifest()->isRequired('forms::onu')); + + $this->manifest()->requirements()->flush(); + Config::set('forms.assets.styles', false); + + $this->renderBlade(''); + + $this->assertFalse($this->manifest()->isRequired('forms::onu')); + $this->assertTrue($this->manifest()->isRequired('forms::forms')); + } + + #[Test] + public function bootstrap_6_components_never_load_the_onu_stylesheet(): void + { + $this->setFrameworkBootstrap6(); + + $this->renderBlade(''); + + $this->assertFalse($this->manifest()->isRequired('forms::onu')); + } + + #[Test] + public function assets_can_be_turned_off(): void + { + Config::set('forms.assets.enabled', false); + + $this->renderBlade(''); + + $this->assertEquals([], $this->manifest()->required()); + } + + #[Test] + public function it_injects_the_prebuilt_tags_once_into_html_pages(): void + { + Config::set('manifest.inject', true); + $this->setFrameworkOnu(); + + Route::get('/islands/create', fn () => Blade::render(<<<'BLADE' + ރަށް + + + + BLADE))->middleware('web'); + + $html = $this->call('GET', '/islands/create')->getContent(); + + $this->assertEquals(1, preg_match_all('/]+src="[^"]*forms-[^"]*\.js"/', $html)); + $this->assertEquals(1, preg_match_all('/]+src="[^"]*select-[^"]*\.js"/', $html)); + $this->assertMatchesRegularExpression('/]+href="[^"]*select-[^"]*\.css"[^>]*>.*<\/head>/s', $html); + $this->assertMatchesRegularExpression('/]+href="[^"]*onu-standalone-[^"]*\.css"/', $html); + } +} diff --git a/tests/Feature/BulkTest.php b/tests/Feature/BulkTest.php index f594743..96766a1 100644 --- a/tests/Feature/BulkTest.php +++ b/tests/Feature/BulkTest.php @@ -3,28 +3,14 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class BulkTest extends TestCase { - public function test_it_can_generated_bulk_actions_for_material_admin_26() + #[Test] + public function it_can_generated_bulk_actions_for_bootstrap_6() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('bulk-actions'); - - $this->visit('/bulk-actions') - ->seeElement('select') - ->seeElement('option[value="approve"]') - ->seeElement('option[value="ban"]') - ->seeElement('option[value="delete"]') - ->seeElement('button[type="submit"]') - ->within('button[type="submit"]', function () { - $this->see('Apply'); - }); - } - - public function test_it_can_generated_bulk_actions_for_bootstrap_5() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('bulk-actions'); $this->visit('/bulk-actions') diff --git a/tests/Feature/ButtonTest.php b/tests/Feature/ButtonTest.php index a910382..44efbd8 100644 --- a/tests/Feature/ButtonTest.php +++ b/tests/Feature/ButtonTest.php @@ -3,91 +3,53 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class ButtonTest extends TestCase { - public function test_it_can_generate_bootstrap_5_button_group() + #[Test] + public function it_can_generate_bootstrap_6_button_group() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('button-group'); $this->visit('/button-group') ->seeElement('div.btn-group') ->within('div.btn-group', function () { - $this->seeElement('button.btn'); + $this->seeElement('button.btn-solid.theme-primary'); }); } - public function test_it_can_generate_material_admin_26_group() + #[Test] + public function it_can_generate_bootstrap_6_button() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('button-group'); - - $this->visit('/button-group') - ->seeElement('div.button-group.inline-btn-group') - ->within('div.button-group', function () { - $this->seeElement('button.btn'); - }); - } - - public function test_it_can_generate_bootstrap_5_button() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('button'); - - $this->visit('/button') - ->seeElement('button[type="button"].btn.btn-danger') - ->seeInElement('button[type="button"].btn.btn-danger', 'Button'); - } - - public function test_it_can_generate_material_admin_26_button() - { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('button'); $this->visit('/button') - ->seeElement('button[type="button"].btn.btn-danger') - ->seeInElement('button[type="button"].btn.btn-danger', 'Button'); + ->seeElement('button[type="button"].btn-solid.theme-danger') + ->seeInElement('button[type="button"].btn-solid.theme-danger', 'Button'); } - public function test_it_can_generate_bootstrap_5_submit_button() + #[Test] + public function it_can_generate_bootstrap_6_submit_button() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('button-submit'); $this->visit('/button-submit') - ->seeElement('button[type="submit"].btn.btn-danger') - ->seeInElement('button[type="submit"].btn.btn-danger', 'Button'); + ->seeElement('button[type="submit"].btn-solid.theme-danger') + ->seeInElement('button[type="submit"].btn-solid.theme-danger', 'Button'); } - public function test_it_can_generate_material_admin_26_submit_button() + #[Test] + public function it_can_generate_bootstrap_6_link_button() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('button-submit'); - - $this->visit('/button-submit') - ->seeElement('button[type="submit"].btn.btn-danger') - ->seeInElement('button[type="submit"].btn.btn-danger', 'Button'); - } - - public function test_it_can_generate_bootstrap_5_link_button() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('button-link'); $this->visit('/button-link') - ->seeElement('a[href="/test"].btn.btn-danger') - ->seeInElement('a[href="/test"].btn.btn-danger', 'Button'); + ->seeElement('a[href="/test"].btn-solid.theme-danger') + ->seeInElement('a[href="/test"].btn-solid.theme-danger', 'Button'); } - - public function test_it_can_generate_material_admin_26_link_button() - { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('button-link'); - - $this->visit('/button-link') - ->seeElement('a[href="/test"].btn.btn-danger') - ->seeInElement('a[href="/test"].btn.btn-danger', 'Button'); - } - } diff --git a/tests/Feature/CardTest.php b/tests/Feature/CardTest.php index 6c6fca9..be609d3 100644 --- a/tests/Feature/CardTest.php +++ b/tests/Feature/CardTest.php @@ -3,12 +3,14 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class CardTest extends TestCase { - public function test_it_can_generate_bootstrap_5_card_title() + #[Test] + public function it_can_generate_bootstrap_6_card_title() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-card-title'); $this->visit('/form-card-title') @@ -16,29 +18,10 @@ public function test_it_can_generate_bootstrap_5_card_title() ->seeInElement('h5.card-title', 'Card title'); } - public function test_it_can_generate_material_admin_26_card_title() + #[Test] + public function it_can_generate_bootstrap_6_card_subtitle() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-card-title'); - - $this->visit('/form-card-title') - ->seeElement('h4.card-title') - ->seeInElement('h4.card-title', 'Card title'); - } - - public function test_it_can_generate_bootstrap_5_card_subtitle() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('form-card-subtitle'); - - $this->visit('/form-card-subtitle') - ->seeElement('h6.card-subtitle') - ->seeInElement('h6.card-subtitle', 'Card subtitle'); - } - - public function test_it_can_generate_material_admin_26_card_subtitle() - { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-card-subtitle'); $this->visit('/form-card-subtitle') @@ -46,9 +29,10 @@ public function test_it_can_generate_material_admin_26_card_subtitle() ->seeInElement('h6.card-subtitle', 'Card subtitle'); } - public function test_it_can_generate_bootstrap_5_card_header() + #[Test] + public function it_can_generate_bootstrap_6_card_header() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-card-header'); $this->visit('/form-card-header') @@ -56,19 +40,10 @@ public function test_it_can_generate_bootstrap_5_card_header() ->seeInElement('div.card-header', 'Card header'); } - public function test_it_can_generate_material_admin_26_card_header() + #[Test] + public function it_can_generate_bootstrap_6_card_footer() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-card-header'); - - $this->visit('/form-card-header') - ->seeElement('div.card-header') - ->seeInElement('div.card-header', 'Card header'); - } - - public function test_it_can_generate_bootstrap_5_card_footer() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-card-footer'); $this->visit('/form-card-footer') @@ -76,19 +51,10 @@ public function test_it_can_generate_bootstrap_5_card_footer() ->seeInElement('div.card-footer', 'Card footer'); } - public function test_it_can_generate_material_admin_26_card_footer() + #[Test] + public function it_can_generate_bootstrap_6_card() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-card-footer'); - - $this->visit('/form-card-footer') - ->seeElement('div.card-footer') - ->seeInElement('div.card-footer', 'Card footer'); - } - - public function test_it_can_generate_bootstrap_5_card() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-card'); $this->visit('/form-card') @@ -107,9 +73,10 @@ public function test_it_can_generate_bootstrap_5_card() }); } - public function test_it_can_generate_bootstrap_5_card_with_header() + #[Test] + public function it_can_generate_bootstrap_6_card_with_header() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-card'); $this->visit('/form-card') @@ -124,26 +91,10 @@ public function test_it_can_generate_bootstrap_5_card_with_header() }); } - public function test_it_can_generate_material_admin_26_card_with_header() + #[Test] + public function it_can_generate_bootstrap_6_card_with_subtitle() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-card'); - - $this->visit('/form-card') - ->seeElement('div#header') - ->within('div#header', function () { - $this - ->seeElement('div.card') - ->within('div.card', function () { - $this->seeElement('div.card-header.text-primary') - ->seeInElement('div.card-header.text-primary', 'This is in the header'); - }); - }); - } - - public function test_it_can_generate_bootstrap_5_card_with_subtitle() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-card'); $this->visit('/form-card') @@ -158,9 +109,10 @@ public function test_it_can_generate_bootstrap_5_card_with_subtitle() }); } - public function test_it_can_generate_bootstrap_5_card_with_image_top() + #[Test] + public function it_can_generate_bootstrap_6_card_with_image_top() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-card'); $this->visit('/form-card') @@ -174,25 +126,10 @@ public function test_it_can_generate_bootstrap_5_card_with_image_top() }); } - public function test_it_can_generate_bootstrap_5_card_with_footer() + #[Test] + public function it_can_generate_bootstrap_6_card_with_footer() { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('form-card'); - - $this->visit('/form-card') - ->seeElement('div#footer') - ->within('div#footer', function () { - $this - ->seeElement('div.card') - ->within('div.card', function () { - $this->seeElement('div.card-footer'); - }); - }); - } - - public function test_it_can_generate_material_admin_26_card_with_footer() - { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-card'); $this->visit('/form-card') @@ -207,62 +144,4 @@ public function test_it_can_generate_material_admin_26_card_with_footer() } // write test for material admin - - public function test_it_can_generate_material_admin_26_card() - { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-card'); - - $this->visit('/form-card') - ->seeElement('div#normal') - ->within('div#normal', function () { - $this - ->seeElement('div.card') - ->within('div.card', function () { - $this->seeElement('div.card-body') - ->within('div.card-body', function () { - $this->seeElement('h4.card-title') - ->seeInElement('h4.card-title', 'Card Title') - ->see('This is a card'); - }); - }); - }); - } - - - - public function test_it_can_generate_material_admin_26_card_with_subtitle() - { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-card'); - - $this->visit('/form-card') - ->seeElement('div#subtitle') - ->within('div#subtitle', function () { - $this - ->seeElement('div.card') - ->within('div.card', function () { - $this->seeElement('h6.card-subtitle') - ->seeInElement('h6.card-subtitle', 'Card Subtitle'); - }); - }); - } - - public function test_it_can_generate_material_admin_26_card_with_image_top() - { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-card'); - - $this->visit('/form-card') - ->seeElement('div#image-top') - ->within('div#image-top', function () { - $this - ->seeElement('div.card') - ->within('div.card', function () { - $this->seeElement('img.card-img-top'); - }); - }); - } - - } diff --git a/tests/Feature/CheckboxTest.php b/tests/Feature/CheckboxTest.php index a41e855..2377ca4 100644 --- a/tests/Feature/CheckboxTest.php +++ b/tests/Feature/CheckboxTest.php @@ -5,10 +5,11 @@ use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class CheckboxTest extends TestCase { - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -16,94 +17,81 @@ public function setUp(): void Config::set('forms.inputs.inline', false); } - public function test_it_can_generate_material_admin_26_checkbox_with_slot() + #[Test] + public function it_can_generate_bootstrap_6_checkbox_with_slot() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('checkbox-slot'); $this->visit('/checkbox-slot') - ->seeElement('div.checkbox') - ->within('div.checkbox', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this - ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"]') - ->seeElement('label.checkbox__label') - ->within('label.checkbox__label', function () { + ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"].check') + ->seeElement('label.check-label') + ->within('label.check-label', function () { $this->seeElement('a') ->seeInElement('a', 'some site'); }); }); } - public function test_it_can_generate_bootstrap_5_checkbox_with_slot() + #[Test] + public function it_can_generate_bootstrap_6_form_checkbox() { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('checkbox-slot'); - - $this->visit('/checkbox-slot') - ->seeElement('div.form-check') - ->within('div.form-check', function () { - $this - ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"].form-check-input') - ->seeElement('label.form-check-label') - ->within('label.form-check-label', function () { - $this->seeElement('a') - ->seeInElement('a', 'some site'); - }); - }); - } - - public function test_it_can_generate_bootstrap_5_form_checkbox() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-checkbox'); $this->visit('/form-checkbox') - ->seeElement('div.form-check') - ->within('div.form-check', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this - ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"].form-check-input') - ->seeElement('label.form-check-label') - ->seeInElement('label.form-check-label[for="check_me"]', 'Check Me'); + ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"].check') + ->seeElement('label.check-label') + ->seeInElement('label.check-label[for="check_me"]', 'Check Me'); }); } - public function test_it_can_generate_bootstrap_5_form_checkbox_that_is_required() + #[Test] + public function it_can_generate_bootstrap_6_form_checkbox_that_is_required() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-checkbox-required'); $this->visit('/form-checkbox-required') - ->seeElement('div.form-check') - ->within('div.form-check', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this - ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"][required].form-check-input') - ->seeElement('label.form-check-label[for="check_me"]') - ->seeInElement('label.form-check-label[for="check_me"]', 'Check Me') - ->within('label.form-check-label[for="check_me"]', function () { + ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"][required].check') + ->seeElement('label.check-label[for="check_me"]') + ->seeInElement('label.check-label[for="check_me"]', 'Check Me') + ->within('label.check-label[for="check_me"]', function () { $this->seeElement('span.required') ->seeInElement('span.required', '*'); }); }); } - public function test_it_can_generate_bootstrap_5_form_checkbox_that_is_selected() + #[Test] + public function it_can_generate_bootstrap_6_form_checkbox_that_is_selected() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-checkbox-selected'); $this->visit('/form-checkbox-selected') - ->seeElement('div.form-check') - ->within('div.form-check', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this - ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"][checked].form-check-input') - ->seeElement('label.form-check-label') - ->seeInElement('label.form-check-label[for="check_me"]', 'Check Me'); + ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"][checked].check') + ->seeElement('label.check-label') + ->seeInElement('label.check-label[for="check_me"]', 'Check Me'); }); } - public function test_it_can_generate_bootstrap_5_form_checkbox_that_is_selected_from_model_binding() + #[Test] + public function it_can_generate_bootstrap_6_form_checkbox_that_is_selected_from_model_binding() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $address = [ 'check_me' => '1', @@ -115,84 +103,50 @@ public function test_it_can_generate_bootstrap_5_form_checkbox_that_is_selected_ })->middleware('web'); $this->visit('/form-checkbox-selected-model-binding') - ->seeElement('div.form-check') - ->within('div.form-check', function () { - $this - ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"][checked].form-check-input') - ->seeElement('label.form-check-label') - ->seeInElement('label.form-check-label[for="check_me"]', 'Check Me'); - }); - } - - public function test_it_can_generate_material_admin_26_form_checkbox() - { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-checkbox'); - - $this->visit('/form-checkbox') - ->seeElement('div.checkbox') - ->within('div.checkbox', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this - ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"]') - ->seeElement('label.checkbox__label') - ->seeInElement('label.checkbox__label[for="check_me"]', 'Check Me'); + ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"][checked].check') + ->seeElement('label.check-label') + ->seeInElement('label.check-label[for="check_me"]', 'Check Me'); }); } - public function test_it_can_generate_material_admin_26_form_checkbox_that_is_required() + #[Test] + public function it_can_generate_bootstrap_6_inline_form_checkbox_that_is_required() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-checkbox-required'); - - $this->visit('/form-checkbox-required') - ->seeElement('div.checkbox') - ->within('div.checkbox', function () { - $this - ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"][required]') - ->seeElement('label.checkbox__label[for="check_me"]') - ->seeInElement('label.checkbox__label[for="check_me"]', 'Check Me') - ->within('label.checkbox__label[for="check_me"]', function () { - $this->seeElement('span.required') - ->seeInElement('span.required', '*'); - }); - }); - } - - - public function test_it_can_generate_material_admin_26_inline_form_checkbox_that_is_required() - { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-checkbox-inline-required'); $this->visit('/form-checkbox-inline-required') - ->seeElement('div.checkbox') - ->within('div.checkbox', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"][required]') - ->seeElement('label.checkbox__label[for="check_me"]') - ->dontSeeInElement('label.checkbox__label[for="check_me"]', 'Check Me') - ->within('label.checkbox__label[for="check_me"]', function () { + ->seeElement('label.check-label[for="check_me"]') + ->dontSeeInElement('label.check-label[for="check_me"]', 'Check Me') + ->within('label.check-label[for="check_me"]', function () { $this->dontSeeElement('span.required') ->dontSeeInElement('span.required', '*'); }); - ; + }); } - public function test_it_can_generate_material_admin_26_form_checkbox_with_helper_text() + #[Test] + public function it_can_generate_bootstrap_6_form_checkbox_with_helper_text() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-checkbox-helper-text'); $this->visit('/form-checkbox-helper-text') - ->seeElement('div.checkbox') - ->within('div.checkbox', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this ->seeElement('input[name="check_me"][id="check_me"][type="checkbox"][required]') - ->seeElement('label.checkbox__label[for="check_me"]'); + ->seeElement('label.check-label[for="check_me"]'); }) - ->seeElement('small.form-text.text-muted') - ->seeInElement('small.form-text.text-muted', 'This is some help text.'); + ->seeElement('small.form-text') + ->seeInElement('small.form-text', 'This is some help text.'); } - } diff --git a/tests/Feature/ConditionalLinkTest.php b/tests/Feature/ConditionalLinkTest.php index d49262e..0140bf5 100644 --- a/tests/Feature/ConditionalLinkTest.php +++ b/tests/Feature/ConditionalLinkTest.php @@ -3,25 +3,14 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class ConditionalLinkTest extends TestCase { - public function test_it_can_render_conditional_links_for_bootstrap_5() + #[Test] + public function it_can_render_conditional_links_for_bootstrap_6() { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('conditional-link'); - - $this->visit('/conditional-link') - ->seeElement('a[href="/admin/dashboard"]') - ->within('a', function () { - $this->seeText('Javaabu'); - }); - - } - - public function test_it_can_render_conditional_links_for_material_admin_26() - { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('conditional-link'); $this->visit('/conditional-link') diff --git a/tests/Feature/ConditionalWrapperTest.php b/tests/Feature/ConditionalWrapperTest.php index 6831ead..e2f7e34 100644 --- a/tests/Feature/ConditionalWrapperTest.php +++ b/tests/Feature/ConditionalWrapperTest.php @@ -3,39 +3,44 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class ConditionalWrapperTest extends TestCase { - public function test_it_can_render_conditional_wrapper_bs5() + #[Test] + public function it_can_render_conditional_wrapper_bs5() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('conditional-wrapper'); $this->visit('/conditional-wrapper') ->seeElement('div[data-enable-elem="#input"]'); } - public function test_it_can_render_conditional_wrapper_powered_by_checkbox_bs5() + #[Test] + public function it_can_render_conditional_wrapper_powered_by_checkbox_bs5() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('conditional-wrapper-checkbox'); $this->visit('/conditional-wrapper-checkbox') ->seeElement('div[data-enable-section-checkbox="#input"]'); } - public function test_it_can_render_conditional_wrapper_material_26() + #[Test] + public function it_can_render_conditional_wrapper_material_26() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('conditional-wrapper'); $this->visit('/conditional-wrapper') ->seeElement('div[data-enable-elem="#input"]'); } - public function test_it_can_render_conditional_wrapper_powered_by_checkbox_material_26() + #[Test] + public function it_can_render_conditional_wrapper_powered_by_checkbox_material_26() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('conditional-wrapper-checkbox'); $this->visit('/conditional-wrapper-checkbox') diff --git a/tests/Feature/DateTest.php b/tests/Feature/DateTest.php index d510e6d..8573051 100644 --- a/tests/Feature/DateTest.php +++ b/tests/Feature/DateTest.php @@ -4,10 +4,11 @@ use Illuminate\Support\Facades\Config; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class DateTest extends TestCase { - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -15,9 +16,10 @@ public function setUp(): void Config::set('forms.inputs.inline', false); } - public function test_it_can_generate_bootstrap_5_date_inputs() + #[Test] + public function it_can_generate_bootstrap_6_date_inputs() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('date-inputs'); $this->visit('/date-inputs') diff --git a/tests/Feature/DhivehiTest.php b/tests/Feature/DhivehiTest.php new file mode 100644 index 0000000..300e234 --- /dev/null +++ b/tests/Feature/DhivehiTest.php @@ -0,0 +1,81 @@ +setLocale('dv'); + } + + /** @return array */ + public static function frameworks(): array + { + return ['onu' => ['onu'], 'bootstrap-6' => ['bootstrap-6']]; + } + + #[Test] + public function it_has_a_dhivehi_line_for_every_english_line(): void + { + $en = require __DIR__ . '/../../lang/en/strings.php'; + $dv = require __DIR__ . '/../../lang/dv/strings.php'; + + $this->assertEquals([], array_keys(array_diff_key($en, $dv)), 'Missing Dhivehi lines'); + $this->assertEquals([], array_keys(array_diff_key($dv, $en)), 'Dhivehi lines with no English line'); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_renders_component_text_in_dhivehi(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade(<<<'BLADE' + + + + BLADE); + + $text = $html->text(); + + $this->assertStringContainsString('އެއްވެސް އެއްޗެއް ޚިޔާރުނުކޮށް', $text); + $this->assertStringContainsString('ކާފު އަތޮޅު', $text); + $this->assertStringContainsString('ފިލްޓަރު ކުރޭ', $text); + $this->assertStringContainsString('ފޮހެލާ', $text); + $this->assertStringContainsString('ފައިލް ސެލެކްޓްކޮށްލާ', $text); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_uses_thaana_labels_and_values_unchanged(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade(''); + + $this->assertStringContainsString('ނަން', $html->filter('label')->text()); + $this->assertEquals('އަޙްމަދު މުޙައްމަދު', $html->filter('input[name="name_dv"]')->attr('value')); + $this->assertEquals('rtl', $html->filter('input[name="name_dv"]')->attr('dir')); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_renders_pagination_in_dhivehi(string $framework): void + { + $paginator = new LengthAwarePaginator(range(1, 10), 30, 10, 2, ['path' => '/']); + + $html = $this->renderBlade('', ['p' => $paginator, 'framework' => $framework]); + + $this->assertStringContainsString('ޖުމްލަ 30 އިން 10 ދައްކަނީ', $html->text()); + $this->assertCount(1, $html->filter('a[rel="prev"][aria-label="ކުރީގެ"]')); + $this->assertCount(1, $html->filter('a[rel="next"][aria-label="ކުރިއަށް"]')); + } +} diff --git a/tests/Feature/FileTest.php b/tests/Feature/FileTest.php index d827118..c1ba75e 100644 --- a/tests/Feature/FileTest.php +++ b/tests/Feature/FileTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; use Javaabu\Forms\Tests\TestSupport\Models\Article; +use PHPUnit\Framework\Attributes\Test; use Spatie\MediaLibrary\MediaCollections\Exceptions\FileDoesNotExist; use Spatie\MediaLibrary\MediaCollections\Exceptions\FileIsTooBig; @@ -14,7 +15,7 @@ class FileTest extends TestCase { use RefreshDatabase; - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -38,11 +39,12 @@ protected function getArticleWithMedia(string $collection = 'featured_image'): A return $article; } - public function test_it_can_render_missing_file_inputs() + #[Test] + public function it_can_render_missing_file_inputs() { $article = $this->getArticleWithMedia(); - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); Route::get('file-missing', function () use ($article) { return view('file-missing') @@ -50,8 +52,8 @@ public function test_it_can_render_missing_file_inputs() })->middleware('web'); $this->visit('/file-missing') - ->seeElement('div.form-group') - ->within('div.form-group', function () { + ->seeElement('div.mb-4') + ->within('div.mb-4', function () { $this->seeElement('div.fileinput.fileinput-new') ->within('div.fileinput', function () { $this->seeElement('span.btn-file') @@ -67,15 +69,16 @@ public function test_it_can_render_missing_file_inputs() }); } - public function test_it_can_render_file_input_with_value() + #[Test] + public function it_can_render_file_input_with_value() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('file-value'); $this->visit('/file-value') - ->seeElement('div.form-group') - ->within('div.form-group', function () { + ->seeElement('div.mb-4') + ->within('div.mb-4', function () { $this->seeElement('div.fileinput.fileinput-exists') ->within('div.fileinput', function () { $this->seeElement('span.btn-file') @@ -83,26 +86,27 @@ public function test_it_can_render_file_input_with_value() $this->seeElement('input[name="featured_image"][type="file"][accept="application/pdf,image/jpeg,image/png"]#featured_image'); }); - $this->seeElement('span.fileinput-filename') - ->within('span.fileinput-filename', function () { - $this->seeElement('a[href="https://example.com/uploads/Important-document.pdf?download=true"]') - ->seeInElement('a', 'Important-document.pdf') - ->dontSeeInElement('a', '?download=true') - ->dontSeeInElement('a', 'https://example.com/uploads/'); + $this->seeElement('a.fileinput-filelink[href="https://example.com/uploads/Important-document.pdf?download=true"]') + ->within('a.fileinput-filelink', function () { + $this->seeElement('svg[data-icon-name="download"]') + ->see('Important-document.pdf') + ->dontSee('?download=true') + ->dontSee('https://example.com/uploads/'); }); }); }); } - public function test_it_can_render_unbound_file_inputs() + #[Test] + public function it_can_render_unbound_file_inputs() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('file-unbound'); $this->visit('/file-unbound') - ->seeElement('div.form-group') - ->within('div.form-group', function () { + ->seeElement('div.mb-4') + ->within('div.mb-4', function () { $this->seeElement('div.fileinput.fileinput-new') ->within('div.fileinput', function () { $this->seeElement('span.btn-file') @@ -118,11 +122,12 @@ public function test_it_can_render_unbound_file_inputs() }); } - public function test_it_can_render_file_inputs_with_attribute_accessor() + #[Test] + public function it_can_render_file_inputs_with_attribute_accessor() { $article = $this->getArticleWithMedia(); - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); Route::get('file-attribute-accessor', function () use ($article) { return view('file-attribute-accessor') @@ -130,8 +135,8 @@ public function test_it_can_render_file_inputs_with_attribute_accessor() })->middleware('web'); $this->visit('/file-attribute-accessor') - ->seeElement('div.form-group') - ->within('div.form-group', function () { + ->seeElement('div.mb-4') + ->within('div.mb-4', function () { $this->seeElement('div.fileinput.fileinput-exists') ->within('div.fileinput', function () { $this->seeElement('span.btn-file') @@ -139,19 +144,20 @@ public function test_it_can_render_file_inputs_with_attribute_accessor() $this->seeElement('input[name="thumbnail"][type="file"][accept="application/pdf,image/jpeg,image/png"]#thumbnail'); }); - $this->seeElement('span.fileinput-filename') - ->within('span.fileinput-filename', function () { - $this->seeElement('a[href="/storage/1/conversions/some-cool-image-thumb.jpg"]'); + $this->seeElement('a.fileinput-filelink[href="/storage/1/conversions/some-cool-image-thumb.jpg"]') + ->within('a.fileinput-filelink', function () { + $this->seeElement('svg[data-icon-name="download"]'); }); }); }); } - public function test_it_can_render_file_inputs_with_accessor() + #[Test] + public function it_can_render_file_inputs_with_accessor() { $article = $this->getArticleWithMedia(); - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); Route::get('file-accessor', function () use ($article) { return view('file-accessor') @@ -159,8 +165,8 @@ public function test_it_can_render_file_inputs_with_accessor() })->middleware('web'); $this->visit('/file-accessor') - ->seeElement('div.form-group') - ->within('div.form-group', function () { + ->seeElement('div.mb-4') + ->within('div.mb-4', function () { $this->seeElement('div.fileinput.fileinput-exists') ->within('div.fileinput', function () { $this->seeElement('span.btn-file') @@ -168,19 +174,20 @@ public function test_it_can_render_file_inputs_with_accessor() $this->seeElement('input[name="photo"][type="file"][accept="application/pdf,image/jpeg,image/png"]#photo'); }); - $this->seeElement('span.fileinput-filename') - ->within('span.fileinput-filename', function () { - $this->seeElement('a[href="/storage/1/some-cool-image.jpg"]'); + $this->seeElement('a.fileinput-filelink[href="/storage/1/some-cool-image.jpg"]') + ->within('a.fileinput-filelink', function () { + $this->seeElement('svg[data-icon-name="download"]'); }); }); }); } - public function test_it_can_render_file_inputs_with_conversion_name() + #[Test] + public function it_can_render_file_inputs_with_conversion_name() { $article = $this->getArticleWithMedia(); - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); Route::get('file-conversion', function () use ($article) { return view('file-conversion') @@ -188,8 +195,8 @@ public function test_it_can_render_file_inputs_with_conversion_name() })->middleware('web'); $this->visit('/file-conversion') - ->seeElement('div.form-group') - ->within('div.form-group', function () { + ->seeElement('div.mb-4') + ->within('div.mb-4', function () { $this->seeElement('div.fileinput.fileinput-exists') ->within('div.fileinput', function () { $this->seeElement('span.btn-file') @@ -197,19 +204,20 @@ public function test_it_can_render_file_inputs_with_conversion_name() $this->seeElement('input[name="featured_image"][type="file"][accept="application/pdf,image/jpeg,image/png"]#featured_image'); }); - $this->seeElement('span.fileinput-filename') - ->within('span.fileinput-filename', function () { - $this->seeElement('a[href="/storage/1/conversions/some-cool-image-thumb.jpg"]'); + $this->seeElement('a.fileinput-filelink[href="/storage/1/conversions/some-cool-image-thumb.jpg"]') + ->within('a.fileinput-filelink', function () { + $this->seeElement('svg[data-icon-name="download"]'); }); }); }); } - public function test_it_can_render_file_inputs_with_collection_name() + #[Test] + public function it_can_render_file_inputs_with_collection_name() { $article = $this->getArticleWithMedia(); - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); Route::get('file-collection', function () use ($article) { return view('file-collection') @@ -217,8 +225,8 @@ public function test_it_can_render_file_inputs_with_collection_name() })->middleware('web'); $this->visit('/file-collection') - ->seeElement('div.form-group') - ->within('div.form-group', function () { + ->seeElement('div.mb-4') + ->within('div.mb-4', function () { $this->seeElement('div.fileinput.fileinput-exists') ->within('div.fileinput', function () { $this->seeElement('span.btn-file') @@ -226,19 +234,20 @@ public function test_it_can_render_file_inputs_with_collection_name() $this->seeElement('input[name="post_image"][type="file"][accept="application/pdf,image/jpeg,image/png"]#post_image'); }); - $this->seeElement('span.fileinput-filename') - ->within('span.fileinput-filename', function () { - $this->seeElement('a[href="/storage/1/some-cool-image.jpg"]'); + $this->seeElement('a.fileinput-filelink[href="/storage/1/some-cool-image.jpg"]') + ->within('a.fileinput-filelink', function () { + $this->seeElement('svg[data-icon-name="download"]'); }); }); }); } - public function test_it_can_show_file_hint() + #[Test] + public function it_can_show_file_hint() { $article = $this->getArticleWithMedia(); - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); Route::get('file-hint', function () use ($article) { return view('file-hint') @@ -246,8 +255,8 @@ public function test_it_can_show_file_hint() })->middleware('web'); $this->visit('/file-hint') - ->seeElement('div.form-group') - ->within('div.form-group', function () { + ->seeElement('div.mb-4') + ->within('div.mb-4', function () { $this->seeElement('div.fileinput.fileinput-exists') ->within('div.fileinput', function () { $this->seeElement('span.btn-file') @@ -255,9 +264,9 @@ public function test_it_can_show_file_hint() $this->seeElement('input[name="featured_image"][type="file"][accept="application/pdf,image/jpeg,image/png"]#featured_image'); }); - $this->seeElement('span.fileinput-filename') - ->within('span.fileinput-filename', function () { - $this->seeElement('a[href="/storage/1/some-cool-image.jpg"]'); + $this->seeElement('a.fileinput-filelink[href="/storage/1/some-cool-image.jpg"]') + ->within('a.fileinput-filelink', function () { + $this->seeElement('svg[data-icon-name="download"]'); }); }); @@ -266,41 +275,12 @@ public function test_it_can_show_file_hint() }); } - public function test_it_can_render_material_admin_26_file_inputs() - { - $article = $this->getArticleWithMedia(); - - $this->setFrameworkMaterialAdmin26(); - - Route::get('file', function () use ($article) { - return view('file') - ->with('article', $article); - })->middleware('web'); - - $this->visit('/file') - ->seeElement('div.form-group') - ->within('div.form-group', function () { - $this->seeElement('div.fileinput.fileinput-exists') - ->within('div.fileinput', function () { - $this->seeElement('span.btn-file') - ->within('span.btn-file', function () { - $this->seeElement('input[name="featured_image"][type="file"][accept="application/pdf,image/jpeg,image/png"]#featured_image'); - }); - - $this->seeElement('span.fileinput-filename') - ->within('span.fileinput-filename', function () { - $this->seeElement('a[href="/storage/1/some-cool-image.jpg"]') - ->seeElement('i.zmdi.zmdi-open-in-new'); - }); - }); - }); - } - - public function test_it_can_render_bootstrap_5_file_inputs() + #[Test] + public function it_can_render_bootstrap_6_file_inputs() { $article = $this->getArticleWithMedia(); - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); Route::get('file', function () use ($article) { return view('file') @@ -319,21 +299,22 @@ public function test_it_can_render_bootstrap_5_file_inputs() $this->seeElement('a.fileinput-filelink[href="/storage/1/some-cool-image.jpg"]') ->within('a.fileinput-filelink', function () { - $this->seeElement('i.fa-regular.fa-arrow-to-bottom'); + $this->seeElement('svg[data-icon-name="download"]'); }) ->seeElement('button.btn-dismiss') ->within('button.btn-dismiss', function () { - $this->seeElement('i.fa-regular.fa-close'); + $this->seeElement('svg[data-icon-name="x"]'); }); }); }); } - public function test_it_can_render_file_upload_inputs() + #[Test] + public function it_can_render_file_upload_inputs() { $article = $this->getArticleWithMedia(); - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); Route::get('file-upload', function () use ($article) { return view('file-upload') @@ -347,7 +328,7 @@ public function test_it_can_render_file_upload_inputs() ->within('div.fileinput', function () { $this->seeElement('.upload-btn') ->within('.upload-btn', function () { - $this->seeElement('i.fa-regular.fa-arrow-to-top') + $this->seeElement('svg[data-icon-name="upload"]') ->seeText('Upload file'); }); }); diff --git a/tests/Feature/FormFilterTest.php b/tests/Feature/FormFilterTest.php index 3eaa452..11a0ca5 100644 --- a/tests/Feature/FormFilterTest.php +++ b/tests/Feature/FormFilterTest.php @@ -4,10 +4,11 @@ use Illuminate\Support\Facades\Config; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class FormFilterTest extends TestCase { - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -16,9 +17,9 @@ public function setUp(): void } // /** @test */ - // public function it_can_generate_bootstrap_5_form_filter() + // public function it_can_generate_bootstrap_6_form_filter() // { - // $this->setFrameworkBootstrap5(); + // $this->setFrameworkBootstrap6(); // $this->registerTestRoute('form-filter'); // // $this->visit('/form-filter') @@ -30,9 +31,10 @@ public function setUp(): void // ->seeElement('input[type="hidden"][name="order"]'); // } - public function test_it_can_generate_material_admin_26_form_filter() + #[Test] + public function it_can_generate_bootstrap_6_form_filter() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-filter'); $this->visit('/form-filter') diff --git a/tests/Feature/FormTest.php b/tests/Feature/FormTest.php index ca25c6d..5b81712 100644 --- a/tests/Feature/FormTest.php +++ b/tests/Feature/FormTest.php @@ -3,10 +3,12 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class FormTest extends TestCase { - public function test_it_can_render_a_form_with_an_action() + #[Test] + public function it_can_render_a_form_with_an_action() { $this->registerTestRoute('form'); diff --git a/tests/Feature/Frameworks/Bootstrap6Test.php b/tests/Feature/Frameworks/Bootstrap6Test.php new file mode 100644 index 0000000..b22319a --- /dev/null +++ b/tests/Feature/Frameworks/Bootstrap6Test.php @@ -0,0 +1,111 @@ +setFrameworkBootstrap6(); + } + + #[Test] + public function it_renders_selects_with_form_control_instead_of_form_select(): void + { + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('select.form-control[name="status"]')); + $this->assertCount(0, $html->filter('.form-select')); + } + + #[Test] + public function it_renders_checkboxes_with_the_check_class_and_no_wrapper_class(): void + { + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('div.form-field > input.check[type="checkbox"]#agree')); + $this->assertCount(1, $html->filter('label.check-label[for="agree"]')); + $this->assertCount(0, $html->filter('.form-check, .form-check-input')); + } + + #[Test] + public function it_maps_button_colours_to_variant_and_theme_classes(): void + { + $html = $this->renderBlade(<<<'BLADE' + A + B + C + D + E + BLADE); + + $this->assertCount(1, $html->filter('button.btn-solid.theme-primary')); + $this->assertCount(1, $html->filter('button.btn-outline.theme-danger')); + $this->assertCount(1, $html->filter('button.btn-subtle.theme-secondary')); + $this->assertCount(1, $html->filter('button.btn-solid.theme-inverse')); + $this->assertCount(1, $html->filter('a.btn-link[href="/x"]')); + } + + #[Test] + public function it_renders_modals_as_native_dialogs(): void + { + $html = $this->renderBlade(<<<'BLADE' + +

Body

+ Footer +
+ BLADE); + + $this->assertCount(1, $html->filter('dialog.dialog.dialog-xl#edit-modal[aria-labelledby="edit-modal-title"]')); + $this->assertEquals('ތަފްސީލު', trim($html->filter('h2.dialog-title#edit-modal-title')->text())); + $this->assertCount(1, $html->filter('.dialog-header button.btn-close[data-bs-dismiss="dialog"]')); + $this->assertCount(1, $html->filter('.dialog-body p')); + $this->assertEquals('Footer', trim($html->filter('.dialog-footer')->text())); + $this->assertCount(0, $html->filter('.modal, .modal-dialog, .modal-content')); + } + + #[Test] + public function it_maps_legacy_modal_size_classes_to_dialog_sizes(): void + { + $html = $this->renderBlade('Body'); + + $this->assertCount(1, $html->filter('dialog.dialog.dialog-sm#m')); + } + + #[Test] + public function it_renders_alerts_with_a_theme_an_icon_and_a_close_button(): void + { + $html = $this->renderBlade('Body'); + + $this->assertCount(1, $html->filter('div.alert.theme-danger[role="alert"]')); + $this->assertCount(1, $html->filter('.alert svg[data-icon-name="circle-x"]')); + $this->assertEquals('Oops', trim($html->filter('h4.alert-heading')->text())); + $this->assertCount(1, $html->filter('.alert button.btn-close[data-bs-dismiss="alert"]')); + $this->assertCount(0, $html->filter('.alert-dismissible')); + } + + #[Test] + public function it_marks_invalid_inputs_for_assistive_technology(): void + { + $this->withViewErrors(['title' => 'ސުރުޚީ ލިޔުން ލާޒިމް.']); + + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('input.form-control.is-invalid[aria-invalid="true"]')); + $this->assertStringContainsString('ސުރުޚީ', $html->filter('.invalid-feedback')->text()); + } + + #[Test] + public function it_uses_prefixed_responsive_classes_for_inline_fields(): void + { + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('div.row.mb-4 > label.col-form-label[class~="sm:col-3"][class~="lg:col-2"]')); + $this->assertCount(1, $html->filter('div.row.mb-4 > div[class~="sm:col-9"][class~="lg:col-10"] input.form-control')); + } +} diff --git a/tests/Feature/Frameworks/FrameworkSmokeTest.php b/tests/Feature/Frameworks/FrameworkSmokeTest.php new file mode 100644 index 0000000..a1410b7 --- /dev/null +++ b/tests/Feature/Frameworks/FrameworkSmokeTest.php @@ -0,0 +1,157 @@ + */ + public static function views(): array + { + $views = [ + 'accordion', + 'accordion-collapse', + 'accordion-header', + 'accordion-item', + 'accordion-slot', + 'accordion-unnamed', + 'boolean-entry', + 'bulk-actions', + 'button', + 'button-group', + 'button-link', + 'button-submit', + 'checkbox-slot', + 'conditional-link', + 'conditional-wrapper', + 'conditional-wrapper-checkbox', + 'date-inputs', + 'file-unbound', + 'file-value', + 'form', + 'form-card', + 'form-card-footer', + 'form-card-header', + 'form-card-subtitle', + 'form-card-title', + 'form-checkbox', + 'form-checkbox-helper-text', + 'form-checkbox-inline-required', + 'form-checkbox-required', + 'form-checkbox-selected', + 'form-filter', + 'form-group-blank', + 'form-help', + 'form-input', + 'form-input-floating', + 'form-input-help', + 'form-input-inline', + 'form-input-prepend-append', + 'form-input-required', + 'form-input-types', + 'form-input-without-label', + 'form-label', + 'form-label-floating', + 'form-label-inline', + 'form-label-required', + 'form-radio', + 'form-radio-required', + 'form-textarea', + 'js-errors', + 'label-blank', + 'label-slot', + 'latitude', + 'longitude', + 'multiple-select-keys', + 'nav-tabs', + 'no-items', + 'per-page', + 'per-page-custom', + 'radio-slot', + 'select-boolean-value', + 'select-multiple', + 'select-placeholder', + 'select-slot', + 'select-sync-field', + 'select-tags', + 'select-without-keys', + 'spoof-method', + 'status', + 'status-slot', + 'table', + 'table-cell', + 'table-cell-array', + 'table-cell-boolean', + 'table-cell-enum', + 'table-cell-model', + 'table-cell-multiline', + 'table-cell-status', + 'table-cell-value', + 'tabs', + 'text-entry', + 'text-entry-array', + 'text-entry-enum', + 'text-entry-model', + 'text-entry-multiline', + 'text-entry-status', + 'text-entry-value', + 'wysiwyg', + ]; + + $cases = []; + + foreach (['onu', 'bootstrap-6'] as $framework) { + foreach ($views as $view) { + $cases["{$framework} {$view}"] = [$framework, $view]; + } + } + + return $cases; + } + + #[Test] + #[DataProvider('frameworks')] + public function it_renders_laravel_pagination_with_every_framework(string $framework): void + { + $paginator = new LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '/articles']); + + $html = new Crawler($paginator->links("forms::{$framework}.pagination")->toHtml()); + + $this->assertStringContainsString('95', $html->text()); + $this->assertCount(1, $html->filter('.page-item.active')); + $this->assertCount(1, $html->filter('a[rel="prev"][href="/articles?page=2"]')); + $this->assertCount(1, $html->filter('a[rel="next"][href="/articles?page=4"]')); + } + + /** @return array */ + public static function frameworks(): array + { + return ['onu' => ['onu'], 'bootstrap-6' => ['bootstrap-6']]; + } + + #[Test] + #[DataProvider('views')] + public function it_renders_every_fixture_view_with_every_framework(string $framework, string $view): void + { + Activity::factory()->count(2)->create(); + + $this->setFramework($framework); + $this->registerTestRoute($view); + + $this->visit("/{$view}") + ->assertResponseOk(); + } +} diff --git a/tests/Feature/Frameworks/OnuTest.php b/tests/Feature/Frameworks/OnuTest.php new file mode 100644 index 0000000..b937893 --- /dev/null +++ b/tests/Feature/Frameworks/OnuTest.php @@ -0,0 +1,181 @@ +setFrameworkOnu(); + + $this->onu = new Onu; + } + + #[Test] + public function it_renders_inputs_with_the_classes_onu_renders(): void + { + $html = $this->renderBlade(''); + + $field = $html->filter('div[data-slot="form-field"][data-label-layout="stacked"]'); + $input = $html->filter('input[data-slot="input"][name="title"]#title'); + + $this->assertCount(1, $field); + $this->assertCount(1, $input); + $this->assertEquals($this->onu->classes('input.input'), $input->attr('class')); + $this->assertEquals('ސުރުޚީ', trim($html->filter('label[data-slot="label"][for="title"]')->getNode(0)->firstChild->textContent)); + $this->assertCount(1, $html->filter('label span[data-slot="required-marker"][aria-hidden="true"]')); + } + + #[Test] + public function it_renders_help_text_and_errors_like_onu_form_field(): void + { + $this->withViewErrors(['title' => 'ސުރުޚީ ލިޔުން ލާޒިމް.']); + + $html = $this->renderBlade(<<<'BLADE' + + ދިވެހިން ލިޔޭ + + BLADE); + + $this->assertEquals('true', $html->filter('input[name="title"]')->attr('aria-invalid')); + $this->assertStringContainsString('ދިވެހިން ލިޔޭ', $html->filter('p[data-slot="help-text"]')->text()); + $this->assertStringContainsString('ސުރުޚީ ލިޔުން ލާޒިމް.', $html->filter('div[data-slot="field-errors"][aria-live="polite"]')->text()); + $this->assertCount(1, $html->filter('div[data-slot="field-errors"] svg[data-icon-name="circle-alert"]')); + } + + #[Test] + public function it_lays_out_inline_fields_with_onu_inline_layout(): void + { + $html = $this->renderBlade(''); + + $field = $html->filter('div[data-slot="form-field"][data-label-layout="inline"]'); + + $this->assertCount(1, $field); + $this->assertEquals($this->onu->classes('field_inline.grid'), $field->filter('div')->eq(1)->attr('class')); + } + + #[Test] + public function it_renders_prepend_and_append_as_an_input_group(): void + { + $html = $this->renderBlade(<<<'BLADE' + + MVR + .00 + + BLADE); + + $this->assertCount(1, $html->filter('div[data-slot="input-group"][role="group"]')); + $this->assertCount(1, $html->filter('div[data-slot="input-group-addon"][data-align="inline-start"]')); + $this->assertCount(1, $html->filter('div[data-slot="input-group-addon"][data-align="inline-end"]')); + $this->assertCount(1, $html->filter('input[data-slot="input-group-control"][name="price"]')); + } + + #[Test] + public function it_renders_selects_as_onu_native_selects(): void + { + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('div[data-slot="native-select-wrapper"] > select[data-slot="native-select"][name="atoll"]')); + $this->assertCount(1, $html->filter('svg[data-slot="native-select-icon"]')); + $this->assertStringContainsString('ކ. އަތޮޅު', $html->filter('option[value="K"]')->text()); + } + + #[Test] + public function it_renders_native_checkboxes_and_radios_with_onu_classes(): void + { + $html = $this->renderBlade(''); + + $checkbox = $html->filter('input[type="checkbox"][data-slot="checkbox"]#agree'); + $radio = $html->filter('input[type="radio"][data-slot="radio"]'); + + $this->assertStringContainsString('checked:bg-primary', $checkbox->attr('class')); + $this->assertStringContainsString('forms-checkbox', $checkbox->attr('class')); + $this->assertStringContainsString('rounded-full', $radio->attr('class')); + $this->assertCount(2, $html->filter('div[data-slot$="-field"] label')); + } + + #[Test] + public function it_maps_button_colours_to_onu_variants(): void + { + $html = $this->renderBlade(<<<'BLADE' + A + B + C + BLADE); + + $danger = $html->filter('button[data-variant="destructive"]'); + + $this->assertNull($danger->attr('type'), 'Like v1, a button without a type keeps the browser default'); + $this->assertStringContainsString($this->onu->classes('button.destructive.default'), $danger->attr('class')); + $this->assertCount(1, $html->filter('button[data-variant="outline"][type="submit"]')); + $this->assertCount(1, $html->filter('a[data-slot="button"][data-variant="outline"][href="/x"]')); + } + + #[Test] + public function it_renders_status_badges_and_alerts_by_tone(): void + { + $html = $this->renderBlade(<<<'BLADE' + ފާސް + ފޯމު ފުރިހަމަ ނުވޭ. + BLADE); + + $this->assertCount(1, $html->filter('span.status[data-slot="status-badge"][data-tone="success"]')); + $this->assertCount(1, $html->filter('div[data-slot="callout"][data-tone="danger"][role="alert"]')); + $this->assertEquals('މައްސަލައެއް', trim($html->filter('[data-slot="alert-title"]')->text())); + $this->assertCount(1, $html->filter('[data-slot="callout"] button[data-part="callout-dismiss"]')); + } + + #[Test] + public function it_renders_modals_as_dialogs_with_onu_modal_classes(): void + { + $html = $this->renderBlade('BodyFooter'); + + $dialog = $html->filter('dialog.forms-dialog#edit[data-slot="dialog-content"][aria-labelledby="edit-title"]'); + + $this->assertCount(1, $dialog); + $this->assertStringContainsString('sm:max-w-4xl', $dialog->attr('class')); + $this->assertCount(1, $html->filter('h2[data-slot="modal-title"]#edit-title')); + $this->assertCount(1, $html->filter('[data-slot="modal-footer"]')); + } + + #[Test] + public function it_renders_cards_accordions_and_tabs_with_onu_slots(): void + { + $html = $this->renderBlade(<<<'BLADE' + + ކާޑު + Body + Footer + + + + General + Details + + BLADE); + + $this->assertCount(1, $html->filter('[data-slot="card"] [data-slot="card-header"] [data-slot="card-title"]')); + $this->assertCount(1, $html->filter('[data-slot="card-footer"]')); + $this->assertCount(1, $html->filter('div[data-slot="accordion"]#faq details[data-slot="accordion-item"][open][name="faq"] summary[data-slot="accordion-trigger"]')); + $this->assertCount(2, $html->filter('[data-slot="tabs-list"][role="tablist"] button[role="tab"][data-slot="tabs-trigger"]')); + $this->assertCount(1, $html->filter('button[role="tab"][data-state="active"][aria-selected="true"]')); + $this->assertCount(1, $html->filter('[data-slot="tabs-content"].hidden')); + } + + #[Test] + public function it_renders_text_entries_as_onu_description_lists(): void + { + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('dl[data-slot="description-list"][data-layout="inline"] dt[data-slot="description-list-label"]')); + $this->assertStringContainsString('އަޙްމަދު', $html->filter('dd[data-slot="description-list-value"]')->text()); + } +} diff --git a/tests/Feature/IconTest.php b/tests/Feature/IconTest.php new file mode 100644 index 0000000..07fdd73 --- /dev/null +++ b/tests/Feature/IconTest.php @@ -0,0 +1,62 @@ +renderBlade(''); + + $svg = $html->filter('svg.forms-icon.size-4[data-icon-name="calendar"][aria-hidden="true"]'); + + $this->assertCount(1, $svg); + $this->assertGreaterThan(0, $svg->filter('path')->count()); + } + + #[Test] + public function it_resolves_logical_icon_names_from_the_config(): void + { + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('svg[data-icon-name="calendar"]')); + } + + #[Test] + public function it_renders_icon_font_classes_as_an_i_element(): void + { + Config::set('forms.icons.date', 'fa-regular fa-calendar'); + + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('i.fa-regular.fa-calendar[aria-hidden="true"]')); + $this->assertCount(1, $html->filter('i.zmdi.zmdi-home')); + $this->assertCount(0, $html->filter('svg')); + } + + #[Test] + public function it_lets_a_framework_override_an_icon(): void + { + Config::set('forms.frameworks.bootstrap-6.icons.clear', 'circle-x'); + + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('svg[data-icon-name="circle-x"]')); + } + + #[Test] + public function it_uses_svg_icons_in_date_inputs(): void + { + $this->setFrameworkBootstrap6(); + + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('.input-group-text svg[data-icon-name="calendar"]')); + $this->assertCount(1, $html->filter('button.btn-date-clear[data-date-clear="#published_at"] svg[data-icon-name="x"]')); + } +} diff --git a/tests/Feature/ImageTest.php b/tests/Feature/ImageTest.php index dc06f20..54ae695 100644 --- a/tests/Feature/ImageTest.php +++ b/tests/Feature/ImageTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; use Javaabu\Forms\Tests\TestSupport\Models\Article; +use PHPUnit\Framework\Attributes\Test; use Spatie\MediaLibrary\MediaCollections\Exceptions\FileDoesNotExist; use Spatie\MediaLibrary\MediaCollections\Exceptions\FileIsTooBig; @@ -14,7 +15,7 @@ class ImageTest extends TestCase { use RefreshDatabase; - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -38,13 +39,12 @@ protected function getArticleWithMedia(string $collection = 'featured_image'): A return $article; } - - - public function test_it_can_render_bootstrap_5_image_inputs() + #[Test] + public function it_can_render_bootstrap_6_image_inputs() { $article = $this->getArticleWithMedia(); - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); Route::get('image', function () use ($article) { return view('image') @@ -68,7 +68,7 @@ public function test_it_can_render_bootstrap_5_image_inputs() }) ->seeElement('.fileinput-image-missing') ->within('.fileinput-image-missing', function () { - $this->seeElement('i.fa-regular.fa-image'); + $this->seeElement('svg[data-icon-name="image"]'); }) ->seeElement('button[data-dismiss="fileinput"]') ->seeInElement('button[data-dismiss="fileinput"]', 'Remove') @@ -79,49 +79,12 @@ public function test_it_can_render_bootstrap_5_image_inputs() }); } - public function test_it_can_render_material_admin_26_image_inputs() - { - $article = $this->getArticleWithMedia(); - - $this->setFrameworkMaterialAdmin26(); - - Route::get('image', function () use ($article) { - return view('image') - ->with('article', $article); - })->middleware('web'); - - $this->visit('/image') - ->seeElement('div.form-group') - ->within('div.form-group', function () { - $this->seeElement('div.fileinput.fileinput-exists') - ->within('div.fileinput', function () { - $this->seeElement('span.btn-file') - ->within('span.btn-file', function () { - $this->seeElement('input[name="featured_image"][type="file"][accept="image/jpeg,image/png,image/gif,image/tiff,image/x-citrix-png,image/x-png,image/svg+xml,image/svg"]#featured_image'); - }); - }) - ->seeElement('div.fileinput-preview') - ->within('div.fileinput-preview', function () { - $this->seeElement('img[src="/storage/1/some-cool-image.jpg"]'); - }) - ->seeElement('.fileinput-image-missing') - ->within('.fileinput-image-missing', function () { - $this->seeElement('i.zmdi.zmdi-image'); - }) - ->seeElement('a[data-dismiss="fileinput"]') - ->seeInElement('a[data-dismiss="fileinput"]', 'Remove') - ->seeElement('.fileinput-image-missing') - ->seeInElement('.fileinput-image-missing', 'Click to select an image') - ->seeElement('.form-text') - ->seeInElement('.form-text', 'Recommended 500px x 500px.'); - }); - } - - public function test_it_can_render_image_upload_inputs() + #[Test] + public function it_can_render_image_upload_inputs() { $article = $this->getArticleWithMedia(); - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); Route::get('image-upload', function () use ($article) { return view('image-upload') @@ -135,7 +98,7 @@ public function test_it_can_render_image_upload_inputs() ->within('div.fileinput', function () { $this->seeElement('.upload-btn') ->within('.upload-btn', function () { - $this->seeElement('i.fa-regular.fa-arrow-to-top') + $this->seeElement('svg[data-icon-name="upload"]') ->seeText('Upload file'); }); }); diff --git a/tests/Feature/InputTest.php b/tests/Feature/InputTest.php index 013c6ab..2fd17b4 100644 --- a/tests/Feature/InputTest.php +++ b/tests/Feature/InputTest.php @@ -4,10 +4,11 @@ use Illuminate\Support\Facades\Config; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class InputTest extends TestCase { - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -15,9 +16,10 @@ public function setUp(): void Config::set('forms.inputs.inline', false); } - public function test_it_can_generate_bootstrap_5_form_inputs_with_input_group() + #[Test] + public function it_can_generate_bootstrap_6_form_inputs_with_input_group() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-input-prepend-append'); $this->visit('/form-input-prepend-append') @@ -36,9 +38,10 @@ public function test_it_can_generate_bootstrap_5_form_inputs_with_input_group() }); } - public function test_it_can_generate_bootstrap_5_form_inputs_without_form_group() + #[Test] + public function it_can_generate_bootstrap_6_form_inputs_without_form_group() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-input-without-label'); $this->visit('/form-input-without-label') @@ -47,20 +50,10 @@ public function test_it_can_generate_bootstrap_5_form_inputs_without_form_group( ->dontSeeElement('div.mb-4'); } - public function test_it_can_generate_material_admin_26_form_inputs_without_form_group() + #[Test] + public function it_can_generate_bootstrap_6_form_inputs() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-input-without-label'); - - $this->visit('/form-input-without-label') - ->seeElement('input[name="title"][value="Lorem ipsum"].form-control') - ->dontSeeElement('label') - ->dontSeeElement('div.form-group'); - } - - public function test_it_can_generate_bootstrap_5_form_inputs() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-input'); $this->visit('/form-input') @@ -72,9 +65,10 @@ public function test_it_can_generate_bootstrap_5_form_inputs() }); } - public function test_it_can_set_form_inputs_as_required() + #[Test] + public function it_can_set_form_inputs_as_required() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-input-required'); $this->visit('/form-input-required') @@ -85,14 +79,15 @@ public function test_it_can_set_form_inputs_as_required() ->seeInElement('label.form-label[for="title"]', 'Title') ->within('label.form-label[for="title"]', function () { $this->seeElement('span.required') - ->seeInElement('span.required', '*'); + ->seeInElement('span.required', '*'); }); }); } - public function test_it_can_generate_bootstrap_5_inline_form_inputs() + #[Test] + public function it_can_generate_bootstrap_6_inline_form_inputs() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-input-inline'); $this->visit('/form-input-inline') @@ -100,16 +95,17 @@ public function test_it_can_generate_bootstrap_5_inline_form_inputs() ->within('div.row.mb-4', function () { $this->seeElement('label.col-form-label[for="title"]') ->seeInElement('label.col-form-label[for="title"]', 'Title') - ->seeElement('div.col-sm-9.col-lg-10') - ->within('div.col-sm-9.col-lg-10', function () { + ->seeElement('div[class~="sm:col-9"][class~="lg:col-10"]') + ->within('div[class~="sm:col-9"][class~="lg:col-10"]', function () { $this->seeElement('input[name="title"][id="title"][value="Lorem ipsum"].form-control'); }); }); } - public function test_it_can_generate_bootstrap_5_floating_form_inputs() + #[Test] + public function it_can_generate_bootstrap_6_floating_form_inputs() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-input-floating'); $this->visit('/form-input-floating') @@ -121,9 +117,10 @@ public function test_it_can_generate_bootstrap_5_floating_form_inputs() }); } - public function test_it_can_display_form_input_help_texts() + #[Test] + public function it_can_display_form_input_help_texts() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-input-help'); $this->visit('/form-input-help') @@ -131,28 +128,14 @@ public function test_it_can_display_form_input_help_texts() ->within('div', function () { $this->seeElement('input[name="title"][value="Lorem ipsum"][id="title"].form-control') ->seeElement('label.form-label[for="title"]') - ->seeElement('div.form-text') - ->seeInElement('div.form-text', 'This is some help text.'); + ->seeElement('small.form-text') + ->seeInElement('small.form-text', 'This is some help text.'); }); } - public function test_it_can_generate_material_admin_26_form_inputs() - { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-input'); - - $this->visit('/form-input') - ->seeElement('div.form-group') - ->within('div.form-group', function () { - $this->seeElement('input[name="title"][value="Lorem ipsum"].form-control') - ->seeElement('i.form-group__bar') - ->seeElement('label[for="title"]') - ->seeInElement('label', 'Title'); - }); - } - - public function test_it_can_generate_different_form_input_types() + #[Test] + public function it_can_generate_different_form_input_types() { $this->registerTestRoute('form-input-types'); @@ -166,7 +149,8 @@ public function test_it_can_generate_different_form_input_types() ->seeElement('input[name="tel"][type="tel"]'); } - public function test_it_can_generate_latitude_inputs() + #[Test] + public function it_can_generate_latitude_inputs() { $this->registerTestRoute('latitude'); @@ -174,7 +158,8 @@ public function test_it_can_generate_latitude_inputs() ->seeElement('input[name="latitude"][type="number"][step="0.000001"][min="-90"][max="90"]'); } - public function test_it_can_generate_longitude_inputs() + #[Test] + public function it_can_generate_longitude_inputs() { $this->registerTestRoute('longitude'); diff --git a/tests/Feature/JsErrorsTest.php b/tests/Feature/JsErrorsTest.php index 056362c..9077a2b 100644 --- a/tests/Feature/JsErrorsTest.php +++ b/tests/Feature/JsErrorsTest.php @@ -2,24 +2,15 @@ namespace Javaabu\Forms\Tests\Feature; -use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class JsErrorsTest extends TestCase { - public function test_it_can_render_bootstrap_5_js_errors() + #[Test] + public function it_can_render_bootstrap_6_js_errors() { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('js-errors'); - - $this->visit('/js-errors') - ->seeElement('ul.invalid-feedback.test-input-error'); - } - - public function test_it_can_render_material_admin_26_js_errors() - { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('js-errors'); $this->visit('/js-errors') diff --git a/tests/Feature/LabelTest.php b/tests/Feature/LabelTest.php index 86c572e..4bf5241 100644 --- a/tests/Feature/LabelTest.php +++ b/tests/Feature/LabelTest.php @@ -4,45 +4,49 @@ use Illuminate\Support\Facades\Config; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class LabelTest extends TestCase { - public function setUp(): void + protected function setUp(): void { parent::setUp(); Config::set('forms.inputs.required_text', 'forms::strings.required_text'); } - public function test_it_can_generate_material_admin_label_with_a_slot() + #[Test] + public function it_can_generate_material_admin_label_with_a_slot() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('label-slot'); $this->visit('/label-slot') ->seeElementCount('label', 1) - ->within('label', function() { + ->within('label', function () { $this->seeElement('a') ->seeInElement('a', 'some site'); }); } - public function test_it_can_generate_bootstrap_5_label_with_a_slot() + #[Test] + public function it_can_generate_bootstrap_6_label_with_a_slot() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('label-slot'); $this->visit('/label-slot') ->seeElementCount('label', 1) - ->within('label', function() { + ->within('label', function () { $this->seeElement('a') - ->seeInElement('a', 'some site'); + ->seeInElement('a', 'some site'); }); } - public function test_it_can_generate_bootstrap_5_blank_form_group() + #[Test] + public function it_can_generate_bootstrap_6_blank_form_group() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-group-blank'); $this->visit('/form-group-blank') @@ -55,9 +59,10 @@ public function test_it_can_generate_bootstrap_5_blank_form_group() }); } - public function test_it_can_generate_bootstrap_5_blank_label() + #[Test] + public function it_can_generate_bootstrap_6_blank_label() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('label-blank'); $page = $this->visit('/label-blank') @@ -65,9 +70,10 @@ public function test_it_can_generate_bootstrap_5_blank_label() ->seeInElement('label', ''); } - public function test_it_can_generate_bootstrap_5_standard_form_labels() + #[Test] + public function it_can_generate_bootstrap_6_standard_form_labels() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-label'); $page = $this->visit('/form-label') @@ -79,75 +85,10 @@ public function test_it_can_generate_bootstrap_5_standard_form_labels() $this->assertEquals('form-label', $class); } - public function test_it_can_generate_bootstrap_5_required_string_for_form_labels() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('form-label-required'); - - $this->visit('/form-label-required') - ->seeElementCount('label', 1) - ->seeInElement('label', 'Title') - ->within('label', function () { - $this->seeElementCount('span.required', 1) - ->seeInElement('span.required', '*'); - }); - } - - public function test_it_can_float_bootstrap_5_labels() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('form-label-floating'); - - $page = $this->visit('/form-label-floating') - ->seeElementCount('label', 1) - ->seeInElement('label', 'Title'); - - $class = $page->crawler()->filter('label')->attr('class'); - - $this->assertNull($class); - } - - public function test_it_can_generate_bootstrap_5_inline_form_labels() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('form-label-inline'); - - $page = $this->visit('/form-label-inline') - ->seeElementCount('label', 1) - ->seeInElement('label', 'Title'); - - $class = $page->crawler()->filter('label')->attr('class'); - - $this->assertEquals('col-sm-3 col-lg-2 col-form-label', $class); - } - - public function test_it_can_display_bootstrap_5_form_help_text() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('form-help'); - - $this->visit('/form-help') - ->seeElementCount('div.form-text', 1) - ->seeInElement('div.form-text', 'This is a help text'); - } - - public function test_it_can_generate_material_admin_26_standard_form_labels() - { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-label'); - - $page = $this->visit('/form-label') - ->seeElementCount('label', 1) - ->seeInElement('label', 'Title'); - - $class = $page->crawler()->filter('label')->attr('class'); - - $this->assertNull($class); - } - - public function test_it_can_generate_material_admin_26_required_string_for_form_labels() + #[Test] + public function it_can_generate_bootstrap_6_required_string_for_form_labels() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-label-required'); $this->visit('/form-label-required') @@ -159,9 +100,10 @@ public function test_it_can_generate_material_admin_26_required_string_for_form_ }); } - public function test_it_can_float_material_admin_26_labels() + #[Test] + public function it_can_float_bootstrap_6_labels() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-label-floating'); $page = $this->visit('/form-label-floating') @@ -173,9 +115,10 @@ public function test_it_can_float_material_admin_26_labels() $this->assertNull($class); } - public function test_it_can_generate_material_admin_26_inline_form_labels() + #[Test] + public function it_can_generate_bootstrap_6_inline_form_labels() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-label-inline'); $page = $this->visit('/form-label-inline') @@ -184,16 +127,17 @@ public function test_it_can_generate_material_admin_26_inline_form_labels() $class = $page->crawler()->filter('label')->attr('class'); - $this->assertEquals('col-sm-3 col-lg-2 col-form-label', $class); + $this->assertEquals('sm:col-3 lg:col-2 col-form-label', $class); } - public function test_it_can_display_material_admin_26_form_help_text() + #[Test] + public function it_can_display_bootstrap_6_form_help_text() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-help'); $this->visit('/form-help') - ->seeElementCount('small.form-text.text-muted', 1) - ->seeInElement('small.form-text.text-muted', 'This is a help text'); + ->seeElementCount('small.form-text', 1) + ->seeInElement('small.form-text', 'This is a help text'); } } diff --git a/tests/Feature/MapInputTest.php b/tests/Feature/MapInputTest.php index 0bb9699..0863009 100644 --- a/tests/Feature/MapInputTest.php +++ b/tests/Feature/MapInputTest.php @@ -7,12 +7,13 @@ use Javaabu\Forms\Tests\TestSupport\Models\City; use MatanYadaev\EloquentSpatial\Objects\Point; use MatanYadaev\EloquentSpatial\Objects\Polygon; +use PHPUnit\Framework\Attributes\Test; class MapInputTest extends TestCase { const SRID = 4326; - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -23,15 +24,16 @@ public function setUp(): void } } - public function test_it_adds_the_maps_script_to_the_scripts_stack() + #[Test] + public function it_adds_the_maps_script_to_the_scripts_stack() { - $city = new City(); + $city = new City; $lat = 4.175804; $lng = 73.509337; $city->coordinates = new Point($lat, $lng, self::SRID); - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); Route::get('map-input', function () use ($city) { return view('map-input') @@ -43,9 +45,10 @@ public function test_it_adds_the_maps_script_to_the_scripts_stack() ->seeInElement('script', 'The Google Maps JavaScript API'); } - public function test_it_can_bind_map_inputs_from_attributes() + #[Test] + public function it_can_bind_map_inputs_from_attributes() { - $city = new City(); + $city = new City; $lat = 4.175804; $lng = 73.509337; $wkt = 'POLYGON((73.5092 4.1758, 73.5094 4.1758, 73.5094 4.1757, 73.5092 4.1757, 73.5092 4.1758))'; @@ -55,7 +58,7 @@ public function test_it_can_bind_map_inputs_from_attributes() $city->boundary = Polygon::fromWkt($wkt, self::SRID); $city->radius = $radius; - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); Route::get('map-input', function () use ($city) { return view('map-input') @@ -73,9 +76,10 @@ public function test_it_can_bind_map_inputs_from_attributes() }); } - public function test_it_can_bind_map_inputs_from_accessors() + #[Test] + public function it_can_bind_map_inputs_from_accessors() { - $city = new City(); + $city = new City; $lat = 4.175804; $lng = 73.509337; $wkt = 'POLYGON((73.5092 4.1758, 73.5094 4.1758, 73.5094 4.1757, 73.5092 4.1757, 73.5092 4.1758))'; @@ -85,7 +89,7 @@ public function test_it_can_bind_map_inputs_from_accessors() $city->boundary = Polygon::fromWkt($wkt, self::SRID); $city->radius = $radius; - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); Route::get('map-input-accessor', function () use ($city) { return view('map-input-accessor') diff --git a/tests/Feature/MultipleSelectTest.php b/tests/Feature/MultipleSelectTest.php index c08e7d7..018980d 100644 --- a/tests/Feature/MultipleSelectTest.php +++ b/tests/Feature/MultipleSelectTest.php @@ -4,10 +4,12 @@ use Illuminate\Http\Request; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class MultipleSelectTest extends TestCase { - public function test_it_posts_all_selected_options() + #[Test] + public function it_posts_all_selected_options() { $this->registerTestRoute('multiple-select-keys', function (Request $request) { $request->validate([ diff --git a/tests/Feature/NavTabsTest.php b/tests/Feature/NavTabsTest.php index 7eb9689..86b1a16 100644 --- a/tests/Feature/NavTabsTest.php +++ b/tests/Feature/NavTabsTest.php @@ -3,12 +3,14 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class NavTabsTest extends TestCase { - public function test_it_can_generate_material_admin_26_nav_tabs() + #[Test] + public function it_can_generate_bootstrap_6_nav_tabs() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('nav-tabs'); $this->visit('/nav-tabs') @@ -36,41 +38,7 @@ public function test_it_can_generate_material_admin_26_nav_tabs() $this->seeElement('li.nav-item') ->seeElement('a.nav-link') ->seeInElement('a.nav-link', 'Icon Tab') - ->seeElement('a.nav-link i[class="zmdi zmdi-shield-security mr-2"]'); - }); - } - - public function test_it_can_generate_bootstrap_5_nav_tabs() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('nav-tabs'); - - $this->visit('/nav-tabs') - ->seeElement('ul.nav.nav-tabs') - ->within('ul.nav.nav-tabs', function () { - // active tabs - $this->seeElement('li.nav-item') - ->seeElement('a.nav-link') - ->seeInElement('a.nav-link.active', 'Active Tab') - ->seeElement('a.nav-link[href="https://active-tab.test"]'); - - // inactive tabs - $this->seeElement('li.nav-item') - ->seeElement('a.nav-link') - ->seeInElement('a.nav-link', 'Inactive Tab') - ->seeElement('a.nav-link[href="https://inactive-tab.test"]'); - - // disabled tabs - $this->seeElement('li.nav-item') - ->seeElement('a.nav-link') - ->seeInElement('a.nav-link.disabled', 'Disabled Tab') - ->seeElement('a.nav-link[href="https://disabled-tab.test"]'); - - // icon tabs - $this->seeElement('li.nav-item') - ->seeElement('a.nav-link') - ->seeInElement('a.nav-link', 'Icon Tab') - ->seeElement('a.nav-link i[class="zmdi zmdi-shield-security mr-2"]'); + ->seeElement('a.nav-link i.zmdi.zmdi-shield-security.me-2[aria-hidden="true"]'); }); } } diff --git a/tests/Feature/NoItemsTest.php b/tests/Feature/NoItemsTest.php index 2d7032e..794cc41 100644 --- a/tests/Feature/NoItemsTest.php +++ b/tests/Feature/NoItemsTest.php @@ -3,31 +3,19 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class NoItemsTest extends TestCase { - public function test_it_can_display_no_items_card_for_material_admin_26() + #[Test] + public function it_can_display_no_items_card_for_bootstrap_6() { $this->withoutExceptionHandling(); - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('no-items'); - - $this->visit('/no-items') - ->seeElement('.no-items') - ->within('.no-items', function () { - $this->see('Let\'s create some new activities'); - }) - ->seeElement('.zmdi.zmdi-file'); - } - - public function test_it_can_display_no_items_card_for_bootstrap_5() - { - $this->withoutExceptionHandling(); - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('no-items'); $this->visit('/no-items') ->see('Let\'s create some new activities') - ->seeElement('.fa-regular.fa-file'); + ->seeElement('svg[data-icon-name="file"]'); } } diff --git a/tests/Feature/OverrideFormsDefaultsMiddlewareTest.php b/tests/Feature/OverrideFormsDefaultsMiddlewareTest.php index f0f292b..a882ed0 100644 --- a/tests/Feature/OverrideFormsDefaultsMiddlewareTest.php +++ b/tests/Feature/OverrideFormsDefaultsMiddlewareTest.php @@ -4,29 +4,32 @@ use Illuminate\Support\Facades\Config; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; +use Route; class OverrideFormsDefaultsMiddlewareTest extends TestCase { - public function test_it_can_use_the_middleware_to_switch_what_theme_to_use() + #[Test] + public function it_can_use_the_middleware_to_switch_what_theme_to_use() { $this->withoutExceptionHandling(); $current_theme = Config::get('forms.framework'); - $this->assertTrue($current_theme === 'bootstrap-5'); + $this->assertEquals('bootstrap-6', $current_theme); - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkOnu(); $current_theme = Config::get('forms.framework'); - $this->assertTrue($current_theme === 'material-admin-26'); + $this->assertEquals('onu', $current_theme); - \Route::get('/change-framework', function () { - return 'Framework changed to Bootstrap-5'; - })->middleware('forms:bootstrap-5'); + Route::get('/change-framework', function () { + return 'Framework changed to Bootstrap 6'; + })->middleware('forms:bootstrap-6'); $this->get('/change-framework') - ->seeText('Framework changed to Bootstrap-5'); + ->seeText('Framework changed to Bootstrap 6'); $current_theme = Config::get('forms.framework'); - $this->assertTrue($current_theme === 'bootstrap-5'); + $this->assertEquals('bootstrap-6', $current_theme); } } diff --git a/tests/Feature/ParityTest.php b/tests/Feature/ParityTest.php new file mode 100644 index 0000000..64f5dc9 --- /dev/null +++ b/tests/Feature/ParityTest.php @@ -0,0 +1,369 @@ + */ + public static function frameworks(): array + { + return ['onu' => ['onu'], 'bootstrap-6' => ['bootstrap-6']]; + } + + #[Test] + #[DataProvider('frameworks')] + public function it_adds_form_group_class_to_any_field_wrapper(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('div.col-span-2 input[name="title"]')); + $this->assertCount(1, $html->filter('div.mt-6 input[name="agree"]')); + $this->assertNull($html->filter('input[name="title"]')->attr('form-group-class')); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_marks_thaana_fields_like_onu(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade(''); + + $input = $html->filter('input[name="name_dv"]'); + $textarea = $html->filter('textarea[name="bio_dv"]'); + + $this->assertEquals('rtl', $input->attr('dir')); + $this->assertEquals('dv', $input->attr('lang')); + $this->assertNotNull($input->attr('data-thaana')); + $this->assertEquals('dv', $textarea->attr('lang')); + $this->assertEquals('true', $textarea->attr('data-auto-resize')); + $this->assertNull($input->attr('thaana')); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_passes_decimals_to_number_inputs(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade(''); + + $this->assertEquals('2', $html->filter('input[name="price"]')->attr('data-decimals')); + $this->assertEquals('0.01', $html->filter('input[name="price"]')->attr('step')); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_renders_button_sizes_icons_and_loading(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade(<<<'BLADE' + ސޭވްކުރޭ + Saving + Link + BLADE); + + $button = $html->filter('button')->eq(0); + + $this->assertCount(1, $button->filter('svg[data-icon-name="plus"][data-icon="inline-start"]')); + $this->assertCount(1, $button->filter('svg[data-icon-name="chevron-right"][data-icon="inline-end"]')); + $this->assertStringContainsString($framework === 'onu' ? 'h-7' : 'btn-sm', $button->attr('class')); + + $submit = $html->filter('button[type="submit"]'); + + $this->assertEquals('true', $submit->attr('aria-busy')); + $this->assertNull($submit->filter('svg[data-slot="button-spinner"]')->attr('hidden')); + + $link = $html->filter('a'); + + $this->assertNull($link->attr('href')); + $this->assertEquals('true', $link->attr('aria-disabled')); + } + + #[Test] + public function it_hides_the_submit_spinner_until_the_form_submits(): void + { + $html = $this->renderBlade('Save'); + + $this->assertNotNull($html->filter('button.animate-submit svg[data-slot="button-spinner"]')->attr('hidden')); + } + + #[Test] + public function it_accepts_clearable_min_chars_and_parent_on_select2(): void + { + $html = $this->renderBlade(''); + + $select = $html->filter('select[name="island"]'); + + $this->assertEquals('#atoll', $select->attr('data-select-parent')); + $this->assertEquals('2', $select->attr('data-minimum-input-length')); + $this->assertNull($select->attr('data-allow-clear')); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_can_show_every_error_message_for_a_field(string $framework): void + { + $this->setFramework($framework); + $this->withViewErrors(['password' => ['ދިގުމިން 8 އަކުރު', 'އަދަދެއް ހިމަނަންވާނެ']]); + + $first = $this->renderBlade(''); + $all = $this->renderBlade(''); + + $this->assertStringNotContainsString('އަދަދެއް', $first->text()); + $this->assertStringContainsString('ދިގުމިން 8 އަކުރު', $all->text()); + $this->assertStringContainsString('އަދަދެއް ހިމަނަންވާނެ', $all->text()); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_renders_an_error_summary_linking_to_each_field(string $framework): void + { + $this->setFramework($framework); + $this->withViewErrors(['name' => 'ނަން ލިޔުއްވާ.', 'address.city' => 'ރަށް ޚިޔާރުކުރައްވާ.']); + + $html = $this->renderBlade(''); + + $summary = $html->filter('form > [data-forms-summary][role="alert"]'); + + $this->assertCount(1, $summary); + $this->assertStringContainsString('ތިރީގައިވާ ގޯސްތައް', $this->renderBladeInDhivehi('')->text()); + $this->assertCount(1, $summary->filter('a[href="#name"]')); + $this->assertCount(1, $summary->filter('a[href="#address-city"]')); + $this->assertNotNull($html->filter('form')->attr('data-focus-on-error')); + } + + #[Test] + public function it_renders_no_summary_by_default_like_v1(): void + { + $this->withViewErrors(['name' => 'Required']); + + $html = $this->renderBlade(''); + + $this->assertCount(0, $html->filter('[data-forms-summary]')); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_renders_a_paginator_with_the_pagination_component(string $framework): void + { + $this->setFramework($framework); + + $paginator = new LengthAwarePaginator(range(1, 10), 30, 10, 2, ['path' => '/posts']); + + $html = $this->renderBlade('', ['paginator' => $paginator]); + + $this->assertCount(1, $html->filter('a[rel="next"][href="/posts?page=3"]')); + } + + #[Test] + public function it_formats_values_like_text_entries(): void + { + $html = $this->renderBlade(' | '); + + $this->assertStringContainsString('މާލެ, އައްޑޫ', $html->text()); + $this->assertStringContainsString('-', $html->text()); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_supports_disabled_accordion_items_modal_descriptions_and_multiple_files(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade(<<<'BLADE' + A + Body + + BLADE); + + $item = $html->filter('details[data-disabled="true"]'); + + $this->assertCount(1, $item); + $this->assertNull($item->attr('open')); + $this->assertStringContainsString('ތަފްޞީލް', $html->filter('dialog#m')->text()); + $this->assertCount(1, $html->filter('input[type="file"][name="attachments[]"][multiple]')); + } + + #[Test] + public function it_can_label_the_per_page_select(): void + { + $html = $this->renderBlade(''); + + $this->assertStringContainsString('Per page', $html->filter('label')->text()); + $this->assertCount(1, $html->filter('select[name="per_page"] option[value="50"][selected]')); + } + + protected function renderBladeInDhivehi(string $template): Crawler + { + app()->setLocale('dv'); + + try { + return $this->renderBlade($template); + } finally { + app()->setLocale('en'); + } + } + + #[Test] + #[DataProvider('frameworks')] + public function it_does_not_duplicate_value_or_id_attributes(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade(''); + + $this->assertEquals(1, substr_count($html->html(), 'value="/back"')); + $this->assertEquals(1, substr_count($html->html(), 'id="agree-terms"')); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_escapes_textarea_values(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade('', ['value' => '']); + + $this->assertCount(0, $html->filter('script')); + $this->assertEquals('', $html->filter('textarea')->text()); + } + + #[Test] + public function it_stops_binding_the_form_model_after_the_form(): void + { + $html = $this->renderBlade('', ['article' => ['title' => 'ރާއްޖެ']]); + + $this->assertEquals('ރާއްޖެ', $html->filter('form input[name="title"]')->attr('value')); + $this->assertEquals('', $html->filter('input#after')->attr('value')); + } + + #[Test] + public function search_forms_respect_the_framework_attribute(): void + { + $this->setFrameworkOnu(); + + $html = $this->renderBlade(''); + + $this->assertCount(1, $html->filter('input.form-control[name="search"]')); + } + + #[Test] + public function radios_are_checked_only_for_their_own_value(): void + { + $html = $this->renderBlade(<<<'BLADE' + + + + + + BLADE, ['island' => ['type' => 'resort', 'is_capital' => true]]); + + $this->assertNull($html->filter('#type-inhabited')->attr('checked')); + $this->assertNotNull($html->filter('#type-resort')->attr('checked')); + $this->assertNotNull($html->filter('input[name="is_capital"]')->attr('checked')); + } + + #[Test] + public function bound_dates_use_the_format_of_their_picker(): void + { + $event = new class extends Model + { + protected $casts = ['starts_at' => 'datetime']; + }; + + $event->starts_at = '2026-11-04 08:30:15'; + + $html = $this->renderBlade(<<<'BLADE' + + + + + BLADE, ['event' => $event]); + + $this->assertEquals('2026-11-04', $html->filter('#d')->attr('value')); + $this->assertEquals('2026-11-04 08:30', $html->filter('#dt')->attr('value')); + $this->assertEquals('08:30', $html->filter('#t')->attr('value')); + $this->assertEquals('08:30:15', $html->filter('#t2')->attr('value')); + $this->assertNull($html->filter('#t2')->attr('date-format')); + } + + #[Test] + #[DataProvider('frameworks')] + public function it_renders_simple_paginators(string $framework): void + { + $this->setFramework($framework); + + $paginator = new Paginator(range(1, 11), 10, 1, ['path' => '/posts']); + + $html = $this->renderBlade('', ['paginator' => $paginator]); + + $this->assertCount(1, $html->filter('a[rel="next"][href="/posts?page=2"]')); + } + + #[Test] + public function conditional_links_hide_for_guests_instead_of_failing(): void + { + $html = $this->renderBlade(''); + + $this->assertCount(0, $html->filter('a')); + $this->assertStringContainsString('ހުޅުމާލެ', $html->text()); + } + + #[Test] + #[DataProvider('frameworks')] + public function tables_render_with_no_bulk(string $framework): void + { + $this->setFramework($framework); + + $html = $this->renderBlade(<<<'BLADE' + + ނަން + ROW + + BLADE); + + $this->assertCount(1, $html->filter('table thead th')); + $this->assertStringContainsString('ROW', $html->filter('table tbody')->text()); + $this->assertCount(0, $html->filter('form')); + } + + #[Test] + #[DataProvider('frameworks')] + public function no_bulk_leaves_out_the_bulk_form_even_with_the_slot(string $framework): void + { + $this->setFramework($framework); + + $template = <<<'BLADE' + + + + + ނަން + ROW + + BLADE; + + $with = $this->renderBlade($template, ['no_bulk' => false]); + $without = $this->renderBlade($template, ['no_bulk' => true]); + + $this->assertCount(1, $with->filter('form[action="/exports/bulk"] table tbody td')); + $this->assertCount(1, $with->filter('form select[name="action"]')); + $this->assertCount(0, $without->filter('form')); + $this->assertCount(1, $without->filter('table tbody td')); + } +} diff --git a/tests/Feature/PerPageTest.php b/tests/Feature/PerPageTest.php index 99f0294..cb1df8d 100644 --- a/tests/Feature/PerPageTest.php +++ b/tests/Feature/PerPageTest.php @@ -3,12 +3,14 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class PerPageTest extends TestCase { - public function test_it_can_see_the_default_per_page_options_bs5() + #[Test] + public function it_can_see_the_default_per_page_options_bs5() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('per-page') ->visit('/per-page') @@ -19,9 +21,10 @@ public function test_it_can_see_the_default_per_page_options_bs5() ->see('500'); } - public function test_it_can_see_the_custom_per_page_options_bs5() + #[Test] + public function it_can_see_the_custom_per_page_options_bs5() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->withoutExceptionHandling(); $this->registerTestRoute('per-page-custom') @@ -31,9 +34,10 @@ public function test_it_can_see_the_custom_per_page_options_bs5() ->see('55'); } - public function test_it_can_see_the_default_per_page_options_material_admin_26() + #[Test] + public function it_can_see_the_default_per_page_options_bootstrap_6() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('per-page') ->visit('/per-page') @@ -44,9 +48,10 @@ public function test_it_can_see_the_default_per_page_options_material_admin_26() ->see('500'); } - public function test_it_can_see_the_custom_per_page_options_material_admin_26() + #[Test] + public function it_can_see_the_custom_per_page_options_bootstrap_6() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->withoutExceptionHandling(); $this->registerTestRoute('per-page-custom') diff --git a/tests/Feature/RadioTest.php b/tests/Feature/RadioTest.php index 7a30991..01efe01 100644 --- a/tests/Feature/RadioTest.php +++ b/tests/Feature/RadioTest.php @@ -3,12 +3,12 @@ namespace Javaabu\Forms\Tests\Feature; use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class RadioTest extends TestCase { - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -16,105 +16,55 @@ public function setUp(): void Config::set('forms.inputs.inline', false); } - public function test_it_can_generate_material_admin_26_radio_with_slot() + #[Test] + public function it_can_generate_bootstrap_6_radio_with_slot() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('radio-slot'); $this->visit('/radio-slot') - ->seeElement('div.radio') - ->within('div.radio', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this - ->seeElement('input[name="check_me"][id="check_me"][type="radio"]') - ->seeElement('label.radio__label') - ->within('label.radio__label', function () { + ->seeElement('input[name="check_me"][id="check_me"][type="radio"].radio') + ->seeElement('label.radio-label') + ->within('label.radio-label', function () { $this->seeElement('a') ->seeInElement('a', 'some site'); }); }); } - public function test_it_can_generate_bootstrap_5_radio_with_slot() + #[Test] + public function it_can_generate_bootstrap_6_form_radio() { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('radio-slot'); - - $this->visit('/radio-slot') - ->seeElement('div.form-check') - ->within('div.form-check', function () { - $this - ->seeElement('input[name="check_me"][id="check_me"][type="radio"].form-check-input') - ->seeElement('label.form-check-label') - ->within('label.form-check-label', function () { - $this->seeElement('a') - ->seeInElement('a', 'some site'); - }); - }); - } - - public function test_it_can_generate_bootstrap_5_form_radio() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('form-radio'); - - $this->visit('/form-radio') - ->seeElement('div.form-check') - ->within('div.form-check', function () { - $this - ->seeElement('input[name="status"][id="status"][type="radio"].form-check-input') - ->seeElement('label.form-check-label') - ->seeInElement('label.form-check-label[for="status"]', 'Status 1'); - }); - } - - public function test_it_can_generate_bootstrap_5_form_radio_that_is_required() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('form-radio-required'); - - $this->visit('/form-radio-required') - ->seeElement('div.form-check') - ->within('div.form-check', function () { - $this - ->seeElement('input[name="status"][id="status"][type="radio"][required].form-check-input') - ->seeElement('label.form-check-label[for="status"]') - ->seeInElement('label.form-check-label[for="status"]', 'Status 1') - ->within('label.form-check-label[for="status"]', function () { - $this->seeElement('span.required') - ->seeInElement('span.required', '*'); - }); - }); - } - - - public function test_it_can_generate_material_admin_26_form_radio() - { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-radio'); $this->visit('/form-radio') - ->seeElement('div.radio') - ->within('div.radio', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this - ->seeElement('input[name="status"][id="status"][type="radio"]') - ->seeElement('label.radio__label') - ->seeInElement('label.radio__label[for="status"]', 'Status 1'); + ->seeElement('input[name="status"][id="status"][type="radio"].radio') + ->seeElement('label.radio-label') + ->seeInElement('label.radio-label[for="status"]', 'Status 1'); }); } - public function test_it_can_generate_material_admin_26_form_radio_that_is_required() + #[Test] + public function it_can_generate_bootstrap_6_form_radio_that_is_required() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-radio-required'); $this->visit('/form-radio-required') - ->seeElement('div.radio') - ->within('div.radio', function () { + ->seeElement('div.form-field') + ->within('div.form-field', function () { $this - ->seeElement('input[name="status"][id="status"][type="radio"][required]') - ->seeElement('label.radio__label[for="status"]') - ->seeInElement('label.radio__label[for="status"]', 'Status 1') - ->within('label.radio__label[for="status"]', function () { + ->seeElement('input[name="status"][id="status"][type="radio"][required].radio') + ->seeElement('label.radio-label[for="status"]') + ->seeInElement('label.radio-label[for="status"]', 'Status 1') + ->within('label.radio-label[for="status"]', function () { $this->seeElement('span.required') ->seeInElement('span.required', '*'); }); diff --git a/tests/Feature/SearchFormTest.php b/tests/Feature/SearchFormTest.php index e895a4a..6b1ce54 100644 --- a/tests/Feature/SearchFormTest.php +++ b/tests/Feature/SearchFormTest.php @@ -3,24 +3,14 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class SearchFormTest extends TestCase { - public function test_it_can_render_a_material_admin_26_search_form() + #[Test] + public function it_can_render_a_bootstrap_6_search_form() { - $this->setFrameworkMaterialAdmin26(); - - $this->registerTestRoute('search-form'); - $this->registerTestRoute('pagination'); - - $this->visit('/search-form?search=hello') - ->seeElement('form.search[action="http://localhost/pagination"]') - ->seeElement('input[name="search"][value="hello"]'); - } - - public function test_it_can_render_a_bootstrap_5_search_form() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('search-form'); $this->registerTestRoute('pagination'); diff --git a/tests/Feature/Select2CascadeTest.php b/tests/Feature/Select2CascadeTest.php index 65a4f12..1636764 100644 --- a/tests/Feature/Select2CascadeTest.php +++ b/tests/Feature/Select2CascadeTest.php @@ -7,13 +7,12 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class Country extends Model { /** * Get the user's first name. - * - * @return \Illuminate\Database\Eloquent\Casts\Attribute */ protected function formattedName(): Attribute { @@ -71,7 +70,8 @@ class Select2CascadeTest extends TestCase { use RefreshDatabase; - public function test_it_can_create_select2_options_from_a_collection() + #[Test] + public function it_can_create_select2_options_from_a_collection() { $countryA = Country::create(['name' => 'Maldives']); $countryB = Country::create(['name' => 'India']); @@ -96,7 +96,8 @@ public function test_it_can_create_select2_options_from_a_collection() ->seeElement('option[value="' . $countryC->getKey() . '"]:not(:selected)'); } - public function test_it_can_create_select2_options_from_a_query_builder() + #[Test] + public function it_can_create_select2_options_from_a_query_builder() { $countryA = Country::create(['name' => 'Maldives']); $countryB = Country::create(['name' => 'India']); @@ -121,10 +122,10 @@ public function test_it_can_create_select2_options_from_a_query_builder() ->seeElement('option[value="' . $countryC->getKey() . '"]:not(:selected)'); } - public function test_it_can_extract_an_accessor_field_from_the_builder() + #[Test] + public function it_can_extract_an_accessor_field_from_the_builder() { - $countryA = Country::create(['name' => 'Maldives']); $countryB = Country::create(['name' => 'India']); $countryC = Country::create(['name' => 'Japan']); @@ -150,10 +151,10 @@ public function test_it_can_extract_an_accessor_field_from_the_builder() ->seeElement('option[value="' . $countryC->getKey() . '"]:not(:selected)'); } - public function test_it_can_extract_a_get_accessor_field_from_the_builder() + #[Test] + public function it_can_extract_a_get_accessor_field_from_the_builder() { - $countryA = Country::create(['name' => 'Maldives']); $countryB = Country::create(['name' => 'India']); $countryC = Country::create(['name' => 'Japan']); @@ -179,10 +180,10 @@ public function test_it_can_extract_a_get_accessor_field_from_the_builder() ->seeElement('option[value="' . $countryC->getKey() . '"]:not(:selected)'); } - public function test_it_can_render_a_select2_cascade() + #[Test] + public function it_can_render_a_select2_cascade() { - $countryA = Country::create(['name' => 'Maldives']); $countryB = Country::create(['name' => 'India']); $countryC = Country::create(['name' => 'Japan']); diff --git a/tests/Feature/Select2Test.php b/tests/Feature/Select2Test.php index ed30819..f9dc28a 100644 --- a/tests/Feature/Select2Test.php +++ b/tests/Feature/Select2Test.php @@ -2,17 +2,19 @@ namespace Javaabu\Forms\Tests\Feature; -use Illuminate\Support\Facades\Route; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; use Javaabu\Forms\Tests\TestSupport\Enums\ArticleStatuses; use Javaabu\Forms\Tests\TestSupport\Models\Article; +use PHPUnit\Framework\Attributes\Test; class Select2Test extends TestCase { use RefreshDatabase; - public function test_it_can_render_a_select_2_basic_element_whose_value_comes_from_an_enum_cast() + #[Test] + public function it_can_render_a_select_2_basic_element_whose_value_comes_from_an_enum_cast() { // create an article $article = new Article([ @@ -39,7 +41,8 @@ public function test_it_can_render_a_select_2_basic_element_whose_value_comes_fr } - public function test_it_can_render_a_select2_basic_element() + #[Test] + public function it_can_render_a_select2_basic_element() { $post = [ 'author' => 2, @@ -65,7 +68,8 @@ public function test_it_can_render_a_select2_basic_element() ->seeElement('option[value="3"]'); } - public function test_it_can_render_a_select_2_basic_element_with_a_label() + #[Test] + public function it_can_render_a_select_2_basic_element_with_a_label() { $post = [ 'author' => 2, @@ -92,7 +96,8 @@ public function test_it_can_render_a_select_2_basic_element_with_a_label() ->seeElement('option[value="3"]'); } - public function test_it_can_render_a_select2_ajax_element() + #[Test] + public function it_can_render_a_select2_ajax_element() { $post = [ 'author' => 2, @@ -114,38 +119,10 @@ public function test_it_can_render_a_select2_ajax_element() ->seeElement('option[value="2"]:selected'); } - public function test_it_can_render_a_select2_element_with_custom_form_group_class_material_admin_26() - { - $this->setFrameworkMaterialAdmin26(); - - $post = [ - 'author' => 2, - ]; - - $options = [ - '1' => 'Arushad', - '2' => 'John', - '3' => 'Amy', - ]; - - Route::get('select2-basic', function () use ($post, $options) { - return view('select2-basic-form-group-class') - ->with('post', $post) - ->with('options', $options); - })->middleware('web'); - - $this->visit('/select2-basic') - ->seeElement('div.custom-class') - ->seeElement('select.select2-basic[id="author"][data-allow-clear="true"][data-placeholder="Nothing Selected"]') - ->seeElement('option[value=""]') - ->seeElement('option[value="1"]') - ->seeElement('option[value="2"]:selected') - ->seeElement('option[value="3"]'); - } - - public function test_it_can_render_a_select2_element_with_custom_form_group_class_bootstrap_5() + #[Test] + public function it_can_render_a_select2_element_with_custom_form_group_class_bootstrap_6() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $post = [ 'author' => 2, @@ -172,7 +149,8 @@ public function test_it_can_render_a_select2_element_with_custom_form_group_clas ->seeElement('option[value="3"]'); } - public function test_it_only_loads_selected_options_for_ajax_selects() + #[Test] + public function it_only_loads_selected_options_for_ajax_selects() { $article1 = Article::create(['title' => 'Article 1']); $article2 = Article::create(['title' => 'Article 2']); diff --git a/tests/Feature/SelectBooleanValueTest.php b/tests/Feature/SelectBooleanValueTest.php index f16f35a..8391882 100644 --- a/tests/Feature/SelectBooleanValueTest.php +++ b/tests/Feature/SelectBooleanValueTest.php @@ -3,10 +3,12 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class SelectBooleanValueTest extends TestCase { - public function test_it_shows_the_select_field() + #[Test] + public function it_shows_the_select_field() { $this->registerTestRoute('select-boolean-value'); @@ -15,7 +17,8 @@ public function test_it_shows_the_select_field() ->seeElement('option[value="0"]'); } - public function test_it_shows_the_false_value_selected() + #[Test] + public function it_shows_the_false_value_selected() { $this->registerTestRoute('select-boolean-value'); diff --git a/tests/Feature/SelectRelationTest.php b/tests/Feature/SelectRelationTest.php index a5ff464..dd6564b 100644 --- a/tests/Feature/SelectRelationTest.php +++ b/tests/Feature/SelectRelationTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class PostBelongsToMany extends Model { @@ -38,18 +39,16 @@ public function comments() } } -class Comment extends Model -{ -} +class Comment extends Model {} class SelectRelationTest extends TestCase { use RefreshDatabase; - public function test_it_handles_belongs_to_many_relationships() + #[Test] + public function it_handles_belongs_to_many_relationships() { - $post = PostBelongsToMany::create(['content' => 'Content']); $commentA = Comment::create(['content' => 'Content A']); @@ -77,10 +76,10 @@ public function test_it_handles_belongs_to_many_relationships() $this->assertCount(1, DB::getQueryLog()); } - public function test_it_handles_morph_many_relationships() + #[Test] + public function it_handles_morph_many_relationships() { - $post = PostMorphMany::create(['content' => 'Content']); $commentA = $post->comments()->create(['content' => 'Content A']); @@ -106,10 +105,10 @@ public function test_it_handles_morph_many_relationships() $this->assertCount(1, DB::getQueryLog()); } - public function test_it_handles_morph_to_many_relationships() + #[Test] + public function it_handles_morph_to_many_relationships() { - $post = PostMorphToMany::create(['content' => 'Content']); $commentA = $post->comments()->create(['content' => 'Content A']); diff --git a/tests/Feature/SelectTest.php b/tests/Feature/SelectTest.php index ca7c8e8..e04ca0d 100644 --- a/tests/Feature/SelectTest.php +++ b/tests/Feature/SelectTest.php @@ -6,12 +6,14 @@ use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; use Javaabu\Forms\Tests\TestSupport\Models\Category; +use PHPUnit\Framework\Attributes\Test; class SelectTest extends TestCase { use RefreshDatabase; - public function test_it_shows_the_slot_if_the_options_are_empty() + #[Test] + public function it_shows_the_slot_if_the_options_are_empty() { $this->registerTestRoute('select-slot'); @@ -21,7 +23,8 @@ public function test_it_shows_the_slot_if_the_options_are_empty() ->seeElement('option[value="c"]'); } - public function test_it_can_render_a_placeholder() + #[Test] + public function it_can_render_a_placeholder() { $this->registerTestRoute('select-placeholder'); @@ -31,7 +34,8 @@ public function test_it_can_render_a_placeholder() ->seeElement('option[value="b"]'); } - public function test_it_adds_a_sync_field_for_multi_selects() + #[Test] + public function it_adds_a_sync_field_for_multi_selects() { $this->registerTestRoute('select-multiple'); @@ -43,7 +47,8 @@ public function test_it_adds_a_sync_field_for_multi_selects() ->seeElement('option[value="c"]'); } - public function test_it_can_exclude_the_sync_field_for_multi_selects() + #[Test] + public function it_can_exclude_the_sync_field_for_multi_selects() { $this->registerTestRoute('select-sync-field'); @@ -53,7 +58,8 @@ public function test_it_can_exclude_the_sync_field_for_multi_selects() ->seeElement('input[type="hidden"][name="custom_sync_name"][value="1"]'); } - public function test_it_includes_old_values_for_tags() + #[Test] + public function it_includes_old_values_for_tags() { $this->registerTestRoute('select-tags'); @@ -65,7 +71,8 @@ public function test_it_includes_old_values_for_tags() ->seeElement('option[value="c"]:selected'); } - public function test_it_can_load_options_from_a_translatable_model() + #[Test] + public function it_can_load_options_from_a_translatable_model() { $category1 = Category::create(['name' => 'Category 1']); $category2 = Category::create(['name' => 'Category 2']); diff --git a/tests/Feature/SelectWithoutKeysTest.php b/tests/Feature/SelectWithoutKeysTest.php index bae5502..55d4add 100644 --- a/tests/Feature/SelectWithoutKeysTest.php +++ b/tests/Feature/SelectWithoutKeysTest.php @@ -4,10 +4,12 @@ use Illuminate\Http\Request; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class SelectWithoutKeysTest extends TestCase { - public function test_it_makes_the_values_numeric() + #[Test] + public function it_makes_the_values_numeric() { $this->registerTestRoute('select-without-keys', function (Request $request) { $request->validate([ @@ -21,7 +23,8 @@ public function test_it_makes_the_values_numeric() ->seeInElement('option[value="2"]', 'c'); } - public function test_it_shows_a_validation_error() + #[Test] + public function it_shows_a_validation_error() { $this->registerTestRoute('select-without-keys', function (Request $request) { $request->validate([ diff --git a/tests/Feature/SpoofMethodTest.php b/tests/Feature/SpoofMethodTest.php index fb4dd96..ba19f0e 100644 --- a/tests/Feature/SpoofMethodTest.php +++ b/tests/Feature/SpoofMethodTest.php @@ -3,10 +3,12 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class SpoofMethodTest extends TestCase { - public function test_it_spoofs_the_methods_for_put_patch_and_delete_forms() + #[Test] + public function it_spoofs_the_methods_for_put_patch_and_delete_forms() { $this->registerTestRoute('spoof-method') ->visit('/spoof-method') diff --git a/tests/Feature/StatusTest.php b/tests/Feature/StatusTest.php index e897c81..cd5a364 100644 --- a/tests/Feature/StatusTest.php +++ b/tests/Feature/StatusTest.php @@ -3,10 +3,12 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class StatusTest extends TestCase { - public function test_it_can_generate_status_with_slot() + #[Test] + public function it_can_generate_status_with_slot() { $this->registerTestRoute('status-slot'); @@ -14,29 +16,18 @@ public function test_it_can_generate_status_with_slot() ->seeElement('span.status') ->within('span.status', function () { $this->seeElement('div.test') - ->seeInElement('div.test', 'Rejected'); + ->seeInElement('div.test', 'Rejected'); }); } - public function test_it_can_generate_material_admin_26_status() + #[Test] + public function it_can_generate_bootstrap_6_status() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('status'); $this->visit('/status') - ->seeElement('span.status.solid.bg-danger') - ->within('span.status', function () { - $this->seeText('Rejected'); - }); - } - - public function test_it_can_generate_bootstrap_5_status() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('status'); - - $this->visit('/status') - ->seeElement('span.status.badge.text-bg-danger') + ->seeElement('span.status.badge.theme-danger') ->within('span.status', function () { $this->seeText('Rejected'); }); diff --git a/tests/Feature/TableCellTest.php b/tests/Feature/TableCellTest.php index b53efbd..96fd3b7 100644 --- a/tests/Feature/TableCellTest.php +++ b/tests/Feature/TableCellTest.php @@ -3,12 +3,14 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class TableCellTest extends TestCase { - public function test_it_can_render_enum_table_cell_entries_for_bootstrap_5() + #[Test] + public function it_can_render_enum_table_cell_entries_for_bootstrap_6() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table-cell-enum'); $this->visit('/table-cell-enum') @@ -19,44 +21,16 @@ public function test_it_can_render_enum_table_cell_entries_for_bootstrap_5() } - public function test_it_can_render_enum_table_cell_entries_for_material_admin_26() + #[Test] + public function it_can_render_status_table_cell_entries_for_bootstrap_6() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('table-cell-enum'); - - $this->visit('/table-cell-enum') - ->seeElement('td') - ->within('td', function () { - $this->seeText('Report Article'); - }); - - } - - public function test_it_can_render_status_table_cell_entries_for_bootstrap_5() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('table-cell-status'); - - $this->visit('/table-cell-status') - ->seeElement('td') - ->within('td', function () { - $this->seeElement('span.status.text-bg-success') - ->within('span.status', function () { - $this->seeText('Published'); - }); - }); - - } - - public function test_it_can_render_status_table_cell_entries_for_material_admin_26() - { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table-cell-status'); $this->visit('/table-cell-status') ->seeElement('td') ->within('td', function () { - $this->seeElement('span.status.bg-success') + $this->seeElement('span.status.badge.theme-success') ->within('span.status', function () { $this->seeText('Published'); }); @@ -64,9 +38,10 @@ public function test_it_can_render_status_table_cell_entries_for_material_admin_ } - public function test_it_can_render_boolean_table_cells() + #[Test] + public function it_can_render_boolean_table_cells() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table-cell-boolean'); $this->visit('/table-cell-boolean') @@ -74,9 +49,10 @@ public function test_it_can_render_boolean_table_cells() ->seeInElement('td', 'Yes'); } - public function test_it_can_render_array_text_table_cells() + #[Test] + public function it_can_render_array_text_table_cells() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table-cell-array'); $this->visit('/table-cell-array') @@ -84,9 +60,10 @@ public function test_it_can_render_array_text_table_cells() ->seeInElement('td', 'orange'); } - public function test_it_can_render_multiline_text_table_cells() + #[Test] + public function it_can_render_multiline_text_table_cells() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table-cell-multiline'); $this->visit('/table-cell-multiline') @@ -94,9 +71,10 @@ public function test_it_can_render_multiline_text_table_cells() ->seeInElement('td', "Javaabu
\nCompany"); } - public function test_it_can_set_the_table_cell_from_model() + #[Test] + public function it_can_set_the_table_cell_from_model() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table-cell-model'); $this->visit('/table-cell-model') @@ -107,9 +85,10 @@ public function test_it_can_set_the_table_cell_from_model() }); } - public function test_it_can_set_the_table_cell_from_value() + #[Test] + public function it_can_set_the_table_cell_from_value() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table-cell-value'); $this->visit('/table-cell-value') @@ -117,19 +96,10 @@ public function test_it_can_set_the_table_cell_from_value() ->seeInElement('td', 'Javaabu'); } - public function test_it_can_generate_bootstrap_5_table_cell() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('table-cell'); - - $this->visit('/table-cell') - ->seeElement('td') - ->seeInElement('td', 'Javaabu'); - } - - public function test_it_can_generate_material_admin_26_table_cell() + #[Test] + public function it_can_generate_bootstrap_6_table_cell() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table-cell'); $this->visit('/table-cell') diff --git a/tests/Feature/TableTest.php b/tests/Feature/TableTest.php index a77c00d..b48147c 100644 --- a/tests/Feature/TableTest.php +++ b/tests/Feature/TableTest.php @@ -5,14 +5,16 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Javaabu\Forms\Tests\TestCase; use Javaabu\Forms\Tests\TestSupport\Models\Activity; +use PHPUnit\Framework\Attributes\Test; class TableTest extends TestCase { use RefreshDatabase; - public function test_it_can_generate_bootstrap_5_table() + #[Test] + public function it_can_generate_bootstrap_6_table() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table'); $this->visit('table') @@ -47,9 +49,10 @@ public function test_it_can_generate_bootstrap_5_table() }); } - public function test_it_can_generate_bootstrap_5_striped_table() + #[Test] + public function it_can_generate_bootstrap_6_striped_table() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table'); $this->visit('table') @@ -59,9 +62,10 @@ public function test_it_can_generate_bootstrap_5_striped_table() }); } - public function test_it_can_generate_material_26_table() + #[Test] + public function it_can_generate_a_table_with_row_checkboxes() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table'); $this->visit('table') @@ -91,7 +95,7 @@ public function test_it_can_generate_material_26_table() ->within('tr', function () use ($activity) { $this ->seeElement('.td-checkbox') - ->seeElement('input[type="checkbox"][id="activities-'. $activity->id . '-row-check"]') + ->seeElement('input[type="checkbox"][id="activities-' . $activity->id . '-row-check"]') ->seeInElement('td', $activity->id) ->seeInElement('td', $activity->date_a) ->seeInElement('td', $activity->date_b) @@ -106,14 +110,15 @@ public function test_it_can_generate_material_26_table() // Pagination check $this->seeElement('div.dataTables_info'); - //$this->seeInElement('div.dataTables_info', 'Showing 2 from total 20 entries'); + // $this->seeInElement('div.dataTables_info', 'Showing 2 from total 20 entries'); $this->seeInElement('div', 'Previous'); }); } - public function test_it_displays_empty_table_message_when_no_matching_rows() + #[Test] + public function it_displays_empty_table_message_when_no_matching_rows() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('table'); $this->visit('table') diff --git a/tests/Feature/TabsTest.php b/tests/Feature/TabsTest.php index db976a4..2acfdb7 100644 --- a/tests/Feature/TabsTest.php +++ b/tests/Feature/TabsTest.php @@ -3,79 +3,14 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class TabsTest extends TestCase { - public function test_it_can_generate_material_admin_26_tabs() + #[Test] + public function it_can_generate_bootstrap_6_tabs() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('tabs'); - - $this->visit('/tabs') - ->seeElement('div.tab-container') - ->within('.tab-container', function () { - $this->seeElement('ul.nav.nav-tabs') - ->within('ul.nav.nav-tabs', function () { - // first tab - $this->seeElement('li.nav-item:nth-child(1)') - ->within('li.nav-item:nth-child(1)', function () { - $this->seeElement('a.nav-link[href="#first-tab"][data-toggle="tab"][role="tab"]') - ->seeInElement('a.nav-link', 'First Tab Title') - ->dontSeeElement('a.active'); - }); - - // active tab - $this->seeElement('li.nav-item:nth-child(2)') - ->within('li.nav-item:nth-child(2)', function () { - $this->seeElement('a.nav-link.active[href="#active-tab"][data-toggle="tab"][role="tab"]') - ->seeInElement('a.nav-link', 'Active Tab'); - }); - - // disabled tab - $this->seeElement('li.nav-item:nth-child(3)') - ->within('li.nav-item:nth-child(3)', function () { - $this->seeElement('a.nav-link.disabled[href="#disabled-tab"][data-toggle="tab"][role="tab"]') - ->seeInElement('a.nav-link', 'Disabled Tab') - ->dontSeeElement('a.active'); - }); - - // external tab - $this->seeElement('li.nav-item:nth-child(4)') - ->within('li.nav-item:nth-child(4)', function () { - $this->seeElement('a.nav-link[href="http://example.com"]') - ->seeInElement('a.nav-link', 'External Tab') - ->dontSeeElement('a.active'); - }); - }); - - $this->seeElement('div.tab-content') - ->within('.tab-content', function () { - $this->seeElementCount('div.tab-pane', 3); - - // first tab - $this->seeElement('div.tab-pane#first-tab[role="tabpanel"]') - ->dontSeeElement('#first-tab.active') - ->seeInElement('#first-tab', 'First Tab Content'); - - // active tab - $this->seeElement('div.tab-pane.active.show#active-tab[role="tabpanel"]') - ->seeInElement('#active-tab', 'Active Tab Content'); - - // disabled tab - $this->seeElement('div.tab-pane#disabled-tab[role="tabpanel"]') - ->dontSeeElement('#disabled.active') - ->seeInElement('#disabled-tab', 'Disabled Tab Content'); - - // external tab - $this->dontSeeElement('div.tab-pane#external-tab'); - }); - }); - - } - - public function test_it_can_generate_bootstrap_5_tabs() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('tabs'); $this->visit('/tabs') diff --git a/tests/Feature/TextEntryTest.php b/tests/Feature/TextEntryTest.php index 798d832..bc887c6 100644 --- a/tests/Feature/TextEntryTest.php +++ b/tests/Feature/TextEntryTest.php @@ -3,12 +3,14 @@ namespace Javaabu\Forms\Tests\Feature; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class TextEntryTest extends TestCase { - public function test_it_can_render_enum_entries_for_bootstrap_5() + #[Test] + public function it_can_render_enum_entries_for_bootstrap_6() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('text-entry-enum'); $this->visit('/text-entry-enum') @@ -24,27 +26,10 @@ public function test_it_can_render_enum_entries_for_bootstrap_5() } - public function test_it_can_render_enum_entries_for_material_admin_26() + #[Test] + public function it_can_render_status_entries_for_bootstrap_6() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('text-entry-enum'); - - $this->visit('/text-entry-enum') - ->seeElement('dl') - ->within('dl', function () { - $this->seeElement('dt') - ->seeInElement('dt', 'Type') - ->seeElement('dd') - ->within('dd', function () { - $this->seeText('Report Article'); - }); - }); - - } - - public function test_it_can_render_status_entries_for_bootstrap_5() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('text-entry-status'); $this->visit('/text-entry-status') @@ -54,7 +39,7 @@ public function test_it_can_render_status_entries_for_bootstrap_5() ->seeInElement('dt', 'Status') ->seeElement('dd') ->within('dd', function () { - $this->seeElement('span.status.text-bg-success') + $this->seeElement('span.status.badge.theme-success') ->within('span.status', function () { $this->seeText('Published'); }); @@ -63,30 +48,10 @@ public function test_it_can_render_status_entries_for_bootstrap_5() } - public function test_it_can_render_status_entries_for_material_admin_26() + #[Test] + public function it_can_render_array_entries() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('text-entry-status'); - - $this->visit('/text-entry-status') - ->seeElement('dl') - ->within('dl', function () { - $this->seeElement('dt') - ->seeInElement('dt', 'Status') - ->seeElement('dd') - ->within('dd', function () { - $this->seeElement('span.status.bg-success') - ->within('span.status', function () { - $this->seeText('Published'); - }); - }); - }); - - } - - public function test_it_can_render_array_entries() - { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('text-entry-array'); $this->visit('/text-entry-array') @@ -99,9 +64,10 @@ public function test_it_can_render_array_entries() }); } - public function test_it_can_render_boolean_entries() + #[Test] + public function it_can_render_boolean_entries() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('boolean-entry'); $this->visit('/boolean-entry') @@ -114,9 +80,10 @@ public function test_it_can_render_boolean_entries() }); } - public function test_it_can_render_multiline_text_entries() + #[Test] + public function it_can_render_multiline_text_entries() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('text-entry-multiline'); $this->visit('/text-entry-multiline') @@ -129,9 +96,10 @@ public function test_it_can_render_multiline_text_entries() }); } - public function test_it_can_set_the_text_entry_from_model() + #[Test] + public function it_can_set_the_text_entry_from_model() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('text-entry-model'); $this->visit('/text-entry-model') @@ -147,9 +115,10 @@ public function test_it_can_set_the_text_entry_from_model() }); } - public function test_it_can_set_the_text_entry_from_value() + #[Test] + public function it_can_set_the_text_entry_from_value() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('text-entry-value'); $this->visit('/text-entry-value') @@ -162,24 +131,10 @@ public function test_it_can_set_the_text_entry_from_value() }); } - public function test_it_can_generate_bootstrap_5_text_entry() - { - $this->setFrameworkBootstrap5(); - $this->registerTestRoute('text-entry'); - - $this->visit('/text-entry') - ->seeElement('dl') - ->within('dl', function () { - $this->seeElement('dt') - ->seeInElement('dt', 'Name') - ->seeElement('dd') - ->seeInElement('dd', 'Javaabu'); - }); - } - - public function test_it_can_generate_material_admin_26_text_entry() + #[Test] + public function it_can_generate_bootstrap_6_text_entry() { - $this->setFrameworkMaterialAdmin26(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('text-entry'); $this->visit('/text-entry') diff --git a/tests/Feature/TextareaTest.php b/tests/Feature/TextareaTest.php index 6f81280..af50487 100644 --- a/tests/Feature/TextareaTest.php +++ b/tests/Feature/TextareaTest.php @@ -3,12 +3,12 @@ namespace Javaabu\Forms\Tests\Feature; use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Route; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class TextareaTest extends TestCase { - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -16,9 +16,10 @@ public function setUp(): void Config::set('forms.inputs.inline', false); } - public function test_it_can_generate_bootstrap_5_form_textarea() + #[Test] + public function it_can_generate_bootstrap_6_form_textarea() { - $this->setFrameworkBootstrap5(); + $this->setFrameworkBootstrap6(); $this->registerTestRoute('form-textarea'); $this->visit('/form-textarea') @@ -32,30 +33,24 @@ public function test_it_can_generate_bootstrap_5_form_textarea() }); } - public function test_it_can_generate_material_admin_26_form_textarea() + #[Test] + public function it_adds_the_tinymce_script_to_the_scripts_stack_for_wysiwyg_inputs() { - $this->setFrameworkMaterialAdmin26(); - $this->registerTestRoute('form-textarea'); + $this->registerTestRoute('wysiwyg'); - $this->visit('/form-textarea') - ->seeElement('div.form-group') - ->within('div.form-group', function () { - $this->seeElement('textarea[name="title"][id="title"].form-control') - ->seeElement('textarea[rows="3"]') - ->seeInElement('textarea[name="title"][id="title"].form-control', 'Lorem ipsum') - ->seeElement('i.form-group__bar') - ->seeElement('label[for="title"]') - ->seeInElement('label', 'Title'); - }); + $this->visit('/wysiwyg') + ->seeElement('textarea[name="title"][id="title"].wysiwyg') + ->seeElement('script[src$="vendors/tinymce/tinymce.min.js"]'); } - public function test_it_adds_the_tinymce_script_to_the_scripts_stack_for_wysiwyg_inputs() + #[Test] + public function it_loads_tinymce_from_the_configured_url(): void { + config()->set('forms.wysiwyg.tinymce_url', 'https://cdn.example.com/tinymce/7/tinymce.min.js'); + $this->registerTestRoute('wysiwyg'); $this->visit('/wysiwyg') - ->seeElement('textarea[name="title"][id="title"].wysiwyg') - ->seeElement('script') - ->seeInElement('script', 'tinymce'); + ->seeElement('script[src="https://cdn.example.com/tinymce/7/tinymce.min.js"]'); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index e2d59d6..6a38191 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,19 +2,25 @@ namespace Javaabu\Forms\Tests; +use Composer\InstalledVersions; use Composer\Semver\VersionParser; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\View; +use Illuminate\Support\MessageBag; +use Illuminate\Support\ViewErrorBag; use Javaabu\Forms\FormsServiceProvider; use Javaabu\Forms\Tests\TestSupport\Providers\TestServiceProvider; use Javaabu\Helpers\HelpersServiceProvider; +use Javaabu\Manifest\ManifestServiceProvider; use Javaabu\Settings\SettingsServiceProvider; use Orchestra\Testbench\BrowserKit\TestCase as BaseTestCase; use Spatie\MediaLibrary\MediaLibraryServiceProvider; +use Symfony\Component\DomCrawler\Crawler; abstract class TestCase extends BaseTestCase { @@ -32,10 +38,10 @@ public static function isLaravel10(): bool public static function isMediaLibrary10(): bool { - return \Composer\InstalledVersions::satisfies(new VersionParser(), 'spatie/laravel-medialibrary', '10.*'); + return InstalledVersions::satisfies(new VersionParser, 'spatie/laravel-medialibrary', '10.*'); } - public function setUp(): void + protected function setUp(): void { parent::setUp(); @@ -43,7 +49,13 @@ public function setUp(): void $this->app['config']->set('session.serialization', 'php'); - //$this->app['config']->set('form-components.framework', env('FORM_COMPONENTS_FRAMEWORK', 'tailwind')); + // Tests check which assets components require; AssetsTest covers injection. + $this->app['config']->set('manifest.inject', false); + + // Behaviour tests render with Bootstrap 6; framework tests pick their own. + $this->app['config']->set('forms.framework', 'bootstrap-6'); + + // $this->app['config']->set('form-components.framework', env('FORM_COMPONENTS_FRAMEWORK', 'tailwind')); View::addLocation(__DIR__ . '/TestSupport/views'); @@ -65,11 +77,34 @@ protected function getPackageProviders($app) SettingsServiceProvider::class, MediaLibraryServiceProvider::class, HelpersServiceProvider::class, + ManifestServiceProvider::class, FormsServiceProvider::class, TestServiceProvider::class, ]; } + /** + * Renders a Blade string and returns a crawler for the HTML. + * + * @param array $data + */ + protected function renderBlade(string $template, array $data = []): Crawler + { + return new Crawler(Blade::render($template, $data)); + } + + /** + * Shares validation errors with the views, as a failed form request would. + * + * @param array> $errors + */ + protected function withViewErrors(array $errors, string $key = 'default'): self + { + View::share('errors', (new ViewErrorBag)->put($key, new MessageBag($errors))); + + return $this; + } + protected function setFramework(string $framework): self { Config::set('forms.framework', $framework); @@ -77,17 +112,17 @@ protected function setFramework(string $framework): self return $this; } - protected function setFrameworkBootstrap5(): self + protected function setFrameworkBootstrap6(): self { - return $this->setFramework('bootstrap-5'); + return $this->setFramework('bootstrap-6'); } - protected function setFrameworkMaterialAdmin26(): self + protected function setFrameworkOnu(): self { - return $this->setFramework('material-admin-26'); + return $this->setFramework('onu'); } - protected function registerTestRoute($uri, callable $post = null): self + protected function registerTestRoute($uri, ?callable $post = null): self { Route::middleware('web')->group(function () use ($uri, $post) { Route::view($uri, $uri)->name($uri); @@ -102,6 +137,7 @@ protected function registerTestRoute($uri, callable $post = null): self /** * Copied from https://github.com/spatie/laravel-medialibrary/issues/623#issuecomment-2110321741 + * * @return $this */ protected function setupFakeMediaDisk(): self diff --git a/tests/TestSupport/Models/ActivityFactory.php b/tests/TestSupport/Models/ActivityFactory.php index cd63ad5..d467a66 100644 --- a/tests/TestSupport/Models/ActivityFactory.php +++ b/tests/TestSupport/Models/ActivityFactory.php @@ -2,7 +2,9 @@ namespace Javaabu\Forms\Tests\TestSupport\Models; -class ActivityFactory extends \Illuminate\Database\Eloquent\Factories\Factory +use Illuminate\Database\Eloquent\Factories\Factory; + +class ActivityFactory extends Factory { protected $model = Activity::class; diff --git a/tests/TestSupport/Models/Article.php b/tests/TestSupport/Models/Article.php index c0241db..c27a3fb 100644 --- a/tests/TestSupport/Models/Article.php +++ b/tests/TestSupport/Models/Article.php @@ -13,8 +13,8 @@ class Article extends Model implements HasMedia { - use InteractsWithMedia; use HasFactory; + use InteractsWithMedia; protected $fillable = [ 'status', diff --git a/tests/TestSupport/Providers/TestServiceProvider.php b/tests/TestSupport/Providers/TestServiceProvider.php index 3599704..67b28d3 100644 --- a/tests/TestSupport/Providers/TestServiceProvider.php +++ b/tests/TestSupport/Providers/TestServiceProvider.php @@ -19,8 +19,5 @@ public function boot() /** * Register the application services. */ - public function register() - { - - } + public function register() {} } diff --git a/tests/TestSupport/database/2024_04_28_124109_create_articles_table.php b/tests/TestSupport/database/2024_04_28_124109_create_articles_table.php index 3a62cc8..94096f8 100644 --- a/tests/TestSupport/database/2024_04_28_124109_create_articles_table.php +++ b/tests/TestSupport/database/2024_04_28_124109_create_articles_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -21,6 +22,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_124110_create_posts_table.php b/tests/TestSupport/database/2024_04_28_124110_create_posts_table.php index f805e85..2fe4753 100644 --- a/tests/TestSupport/database/2024_04_28_124110_create_posts_table.php +++ b/tests/TestSupport/database/2024_04_28_124110_create_posts_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -18,6 +19,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_124111_create_comments_table.php b/tests/TestSupport/database/2024_04_28_124111_create_comments_table.php index 91912c2..4e65130 100644 --- a/tests/TestSupport/database/2024_04_28_124111_create_comments_table.php +++ b/tests/TestSupport/database/2024_04_28_124111_create_comments_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -19,6 +20,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_124112_create_comment_post_table.php b/tests/TestSupport/database/2024_04_28_124112_create_comment_post_table.php index 27ccc1d..4fd0690 100644 --- a/tests/TestSupport/database/2024_04_28_124112_create_comment_post_table.php +++ b/tests/TestSupport/database/2024_04_28_124112_create_comment_post_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -17,6 +18,7 @@ public function up() $table->unsignedBigInteger('comment_id'); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_124113_create_commentables_table.php b/tests/TestSupport/database/2024_04_28_124113_create_commentables_table.php index 656e718..0d1562c 100644 --- a/tests/TestSupport/database/2024_04_28_124113_create_commentables_table.php +++ b/tests/TestSupport/database/2024_04_28_124113_create_commentables_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -18,6 +19,7 @@ public function up() $table->morphs('commentable'); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_124114_create_tags_table.php b/tests/TestSupport/database/2024_04_28_124114_create_tags_table.php index baee6c0..5d29ab0 100644 --- a/tests/TestSupport/database/2024_04_28_124114_create_tags_table.php +++ b/tests/TestSupport/database/2024_04_28_124114_create_tags_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -18,6 +19,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_134113_create_activities_table.php b/tests/TestSupport/database/2024_04_28_134113_create_activities_table.php index c90ae08..98b217c 100644 --- a/tests/TestSupport/database/2024_04_28_134113_create_activities_table.php +++ b/tests/TestSupport/database/2024_04_28_134113_create_activities_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -22,6 +23,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144110_create_countries_table.php b/tests/TestSupport/database/2024_04_28_144110_create_countries_table.php index 5918b8f..c30fcd3 100644 --- a/tests/TestSupport/database/2024_04_28_144110_create_countries_table.php +++ b/tests/TestSupport/database/2024_04_28_144110_create_countries_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -18,6 +19,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144111_create_states_table.php b/tests/TestSupport/database/2024_04_28_144111_create_states_table.php index b1e59be..c85ec8f 100644 --- a/tests/TestSupport/database/2024_04_28_144111_create_states_table.php +++ b/tests/TestSupport/database/2024_04_28_144111_create_states_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -19,6 +20,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144112_create_cities_table.php b/tests/TestSupport/database/2024_04_28_144112_create_cities_table.php index 3247720..78bb0e9 100644 --- a/tests/TestSupport/database/2024_04_28_144112_create_cities_table.php +++ b/tests/TestSupport/database/2024_04_28_144112_create_cities_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -19,6 +20,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_04_28_144113_create_addresses_table.php b/tests/TestSupport/database/2024_04_28_144113_create_addresses_table.php index 73f4142..38dfaf1 100644 --- a/tests/TestSupport/database/2024_04_28_144113_create_addresses_table.php +++ b/tests/TestSupport/database/2024_04_28_144113_create_addresses_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * @@ -19,6 +20,7 @@ public function up() $table->timestamps(); }); } + /** * Reverse the migrations. * diff --git a/tests/TestSupport/database/2024_12_24_123546_create_categories_table.php b/tests/TestSupport/database/2024_12_24_123546_create_categories_table.php index bb61b98..6d0326c 100644 --- a/tests/TestSupport/database/2024_12_24_123546_create_categories_table.php +++ b/tests/TestSupport/database/2024_12_24_123546_create_categories_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class extends Migration +{ /** * Run the migrations. * diff --git a/tests/Unit/FormsDataBinderTest.php b/tests/Unit/FormsDataBinderTest.php index 1d02b07..f884be1 100644 --- a/tests/Unit/FormsDataBinderTest.php +++ b/tests/Unit/FormsDataBinderTest.php @@ -4,21 +4,24 @@ use Javaabu\Forms\FormsDataBinder; use Javaabu\Forms\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class FormsDataBinderTest extends TestCase { - public function test_it_can_bind_targets() + #[Test] + public function it_can_bind_targets() { - $binder = new FormsDataBinder(); + $binder = new FormsDataBinder; $this->assertNull($binder->get()); $binder->bind($array = ['foo' => 'bar']); $this->assertEquals($array, $binder->get()); } - public function test_it_can_bind_multiple_targets() + #[Test] + public function it_can_bind_multiple_targets() { - $binder = new FormsDataBinder(); + $binder = new FormsDataBinder; $binder->bind($targetA = ['foo' => 'bar']); $binder->bind($targetB = ['bar' => 'foo']); diff --git a/tests/Unit/FrameworksTest.php b/tests/Unit/FrameworksTest.php new file mode 100644 index 0000000..067e577 --- /dev/null +++ b/tests/Unit/FrameworksTest.php @@ -0,0 +1,56 @@ +assertInstanceOf(Onu::class, Frameworks::get('onu')); + $this->assertInstanceOf(Bootstrap6::class, Frameworks::get('bootstrap-6')); + $this->assertEquals(['onu', 'bootstrap-6'], Frameworks::names()); + } + + #[Test] + public function it_throws_for_an_unknown_framework(): void + { + $this->expectException(InvalidArgumentException::class); + + Frameworks::get('bootstrap-5'); + } + + #[Test] + public function it_can_register_a_custom_framework(): void + { + Frameworks::register('my-theme', Bootstrap6::class); + + $this->assertInstanceOf(Bootstrap6::class, Frameworks::get('my-theme')); + } + + #[Test] + public function it_maps_bootstrap_colours_to_bootstrap_6_classes(): void + { + $framework = new Bootstrap6; + + $this->assertEquals('btn-solid theme-danger btn-sm', $framework->button('danger', 'sm')); + $this->assertEquals('btn-outline theme-primary', $framework->button('outline-primary')); + $this->assertEquals('badge theme-success', $framework->badge('success')); + $this->assertEquals('alert theme-warning', $framework->alert('warning')); + } + + #[Test] + public function it_defaults_to_the_onu_framework(): void + { + $config = require __DIR__ . '/../../config/config.php'; + + $this->assertEquals('onu', $config['framework']); + } +} diff --git a/tests/Unit/MimeTypesTest.php b/tests/Unit/MimeTypesTest.php new file mode 100644 index 0000000..b8f78e7 --- /dev/null +++ b/tests/Unit/MimeTypesTest.php @@ -0,0 +1,93 @@ +assertArrayNotHasKey('javaabu/helpers', $composer['require']); + $this->assertArrayNotHasKey('javaabu/settings', $composer['require']); + } + + #[Test] + public function it_ships_the_same_lists_as_javaabu_helpers(): void + { + $types = ['image', 'document', 'pdf', 'excel']; + + foreach ($types as $type) { + $this->assertEquals(AllowedMimeTypes::getAllowedMimeTypes($type), MimeTypes::mimeTypes($type), $type); + } + + $mime_types = MimeTypes::mimeTypes($types); + + $this->assertEquals(array_values(AllowedMimeTypes::getExtensions($mime_types)), MimeTypes::extensions($mime_types)); + } + + #[Test] + public function it_gives_mime_types_and_extensions_for_file_types(): void + { + $this->assertContains('image/jpeg', MimeTypes::mimeTypes('image')); + $this->assertContains('application/pdf', MimeTypes::mimeTypes(['image', 'pdf'])); + $this->assertEquals([], MimeTypes::mimeTypes('not-a-type')); + $this->assertEquals(['jpeg', 'png'], MimeTypes::extensions(['image/jpeg', 'image/png', 'unknown/type'])); + } + + #[Test] + public function it_reads_the_max_file_size_from_the_forms_config_without_settings(): void + { + $this->assertGreaterThan(0, MimeTypes::maxFileSize('document')); + + Config::set('forms.defaults.max_upload_file_size', 2048); + + $this->assertEquals(2048, MimeTypes::maxFileSize('document')); + $this->assertEquals('max_image_file_size', MimeTypes::fileSizeSetting('image')); + $this->assertEquals('max_upload_file_size', MimeTypes::fileSizeSetting('document')); + } + + #[Test] + public function it_formats_file_sizes_with_translated_units(): void + { + $this->assertEquals('512KB', MimeTypes::formatFileSize(512)); + $this->assertEquals('1.5MB', MimeTypes::formatFileSize(1536)); + $this->assertEquals('2GB', MimeTypes::formatFileSize(2 * 1024 * 1024)); + } + + #[Test] + public function apps_can_register_their_own_types(): void + { + MimeTypes::register('ebook', ['application/epub+zip']); + MimeTypes::registerExtensions(['application/epub+zip' => 'epub']); + + $this->assertEquals(['application/epub+zip'], MimeTypes::mimeTypes('ebook')); + $this->assertEquals(['epub'], MimeTypes::extensions(['application/epub+zip'])); + + $html = $this->renderBlade(''); + + $this->assertEquals('application/epub+zip', $html->filter('input[type="file"]')->attr('accept')); + $this->assertStringContainsString('.epub', $html->text()); + } +} diff --git a/tests/Unit/SettingsTest.php b/tests/Unit/SettingsTest.php new file mode 100644 index 0000000..2fc419b --- /dev/null +++ b/tests/Unit/SettingsTest.php @@ -0,0 +1,25 @@ +assertEquals(35, Settings::get('per_page', 20)); + } + + #[Test] + public function it_falls_back_to_the_given_default_when_nothing_is_set(): void + { + $this->assertEquals('fallback', Settings::get('a_setting_that_does_not_exist', 'fallback')); + } +} diff --git a/tests/js/conditional.test.ts b/tests/js/conditional.test.ts new file mode 100644 index 0000000..4deb550 --- /dev/null +++ b/tests/js/conditional.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest" + +import { init } from "../../resources/js/conditional" +import { $, fire, html } from "./helpers" + +const input = (selector: string) => $(selector) + +describe("conditional display", () => { + it("enables a section when another field has the value", () => { + html(` + +
+ +
`) + + init() + expect(input("#name").disabled).toBe(true) + expect(input("#name").classList.contains("auto-disabled")).toBe(true) + expect($("#notes").disabled).toBe(true) + expect($("#atoll").disabled).toBe(true) + expect($("#section").hidden).toBe(false) + + $("#type").value = "island" + fire($("#type")) + expect(input("#name").disabled).toBe(false) + expect(input("#name").classList.contains("auto-disabled")).toBe(false) + }) + + it("leaves fields the server disabled alone", () => { + html(` + +
+ +
`) + + init() + $("#type").value = "b" + fire($("#type")) + $("#type").value = "a" + fire($("#type")) + + expect(input("#locked").disabled).toBe(true) + expect(input("#open").disabled).toBe(false) + }) + + it("matches any of a JSON array of values, hides and inverts", () => { + html(` + + +
+ +
`) + + init() + expect($("#section").hidden).toBe(false) + expect(input("#field").disabled).toBe(false) + + input("#three").checked = true + fire(input("#three")) + expect($("#section").hidden).toBe(true) + expect(input("#field").disabled).toBe(true) + }) + + it("compares numbers loosely and reads multiple selects", () => { + html(` + +
`) + + init() + expect($("#section").hidden).toBe(true) + + $("#ids").options[1]!.selected = true + fire($("#ids")) + expect($("#section").hidden).toBe(false) + }) + + it("ignores sections whose element is missing", () => { + html(`
+
`) + + init() + expect(input("#f").disabled).toBe(false) + expect(input("#g").disabled).toBe(false) + }) + + it("enables a section while a checkbox is checked", () => { + html(` + +
+ +
`) + + init() + expect(input("#f").disabled).toBe(true) + expect($("#b").disabled).toBe(true) + expect($("#section").hidden).toBe(true) + + input("#agree").checked = true + fire(input("#agree")) + expect(input("#f").disabled).toBe(false) + expect($("#b").disabled).toBe(false) + expect($("#section").hidden).toBe(false) + }) + + it("disables a section while a checkbox is checked, with data-disable", () => { + html(` +
`) + + init() + expect(input("#f").disabled).toBe(true) + }) + + it("toggles fields with data-toggle-checkbox", () => { + html(``) + + init() + expect(input("#a").disabled).toBe(false) + + input("#c").checked = true + fire(input("#c")) + expect(input("#a").disabled).toBe(true) + expect(input("#b").disabled).toBe(true) + }) + + it("enables and shows fields with data-enable-checkbox", () => { + html(` + +
`) + + init() + expect($("#fields").hidden).toBe(true) + expect(input("#a").disabled).toBe(true) + + input("#c").checked = true + fire(input("#c")) + expect($("#fields").hidden).toBe(false) + expect(input("#a").disabled).toBe(false) + expect($("#s").disabled).toBe(false) + }) + + it("inverts data-enable-checkbox with data-disable", () => { + html(` +
`) + + init() + expect(input("#a").disabled).toBe(false) + expect($("#fields").hidden).toBe(false) + }) + + it("disables fields while a select has a value", () => { + html(` + +
`) + + init() + expect(input("#a").disabled).toBe(false) + expect($("#fields").hidden).toBe(false) + + $("#s").value = "1" + fire($("#s")) + expect(input("#a").disabled).toBe(true) + expect($("#fields").hidden).toBe(true) + }) + + it("is idempotent", () => { + html(``) + + init() + input("#a").disabled = true + init() + expect(input("#a").disabled).toBe(true) + expect(input("#c").getAttribute("data-forms-ready")).toBe("toggle-checkbox") + }) + + it("ignores events from non-elements", () => { + init() + document.dispatchEvent(new Event("change")) + }) +}) diff --git a/tests/js/dates.test.ts b/tests/js/dates.test.ts new file mode 100644 index 0000000..673165c --- /dev/null +++ b/tests/js/dates.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest" + +import { enhance, init, optionsFor } from "../../resources/js/dates" +import { clearDate } from "../../resources/js/inputs" +import { $, html } from "./helpers" + +type Picker = HTMLInputElement & { _flatpickr?: ReturnType } + +const input = (selector: string) => $(selector) + +describe("date pickers", () => { + it("enhances date inputs with the server's format", () => { + html(``) + init() + init() + + const picker = input("#d")._flatpickr! + expect(picker).toBeDefined() + expect(input("#d").type).toBe("text") + expect(input("#d").getAttribute("data-forms-type")).toBe("date") + expect(picker.config.dateFormat).toBe("Y-m-d") + expect(picker.config.allowInput).toBe(true) + expect(picker.config.enableTime).toBe(false) + expect(picker.selectedDates[0]?.getDate()).toBe(5) + + picker.setDate("2024-12-31", true) + expect(input("#d").value).toBe("2024-12-31") + expect(enhance(input("#d"))).toBe(picker) + }) + + it("enhances datetime and time inputs", () => { + html(` + `) + init() + + const datetime = input("#dt")._flatpickr! + expect(datetime.config.dateFormat).toBe("Y-m-d H:i") + expect(datetime.config.enableTime).toBe(true) + expect(datetime.selectedDates[0]?.getHours()).toBe(14) + + const time = input("#t")._flatpickr! + expect(time.config.dateFormat).toBe("H:i") + expect(time.config.noCalendar).toBe(true) + expect(input("#t").value).toBe("09:15") + }) + + it("speaks Dhivehi and lays out right to left", () => { + document.documentElement.setAttribute("lang", "dv") + html(`
`) + init() + + const picker = input("#d")._flatpickr! + expect(picker.l10n.months.longhand[0]).toBe("ޖެނުއަރީ") + expect(picker.l10n.weekdays.longhand[5]).toBe("ހުކުރު") + expect(picker.calendarContainer.getAttribute("dir")).toBe("rtl") + expect(picker.calendarContainer.classList.contains("forms-flatpickr")).toBe(true) + expect(optionsFor(input("#d")).position).toBe("auto right") + }) + + it("stays in English left to right otherwise", () => { + html(``) + const picker = enhance(input("#d")) + expect(picker.l10n.months.longhand[0]).toBe("January") + expect(picker.calendarContainer.getAttribute("dir")).toBe("ltr") + expect(optionsFor(input("#d")).position).toBe("auto") + }) + + it("keeps the calendar inside dialogs and positions it on the input group", () => { + html(`
`) + const options = optionsFor(input("#d")) + expect(options.static).toBe(true) + expect(options.positionElement).toBe($("#g")) + }) + + it("reads min and max", () => { + html(``) + const options = optionsFor(input("#d")) + expect(options.minDate).toBe("2024-01-01") + expect(options.maxDate).toBe("2024-12-31") + }) + + it("clears through the clear button", () => { + html(``) + init() + const change = vi.fn() + input("#d").addEventListener("change", change) + + clearDate(input("#d")) + expect(input("#d").value).toBe("") + expect(input("#d")._flatpickr!.selectedDates).toHaveLength(0) + expect(change).toHaveBeenCalled() + }) +}) diff --git a/tests/js/dom.test.ts b/tests/js/dom.test.ts new file mode 100644 index 0000000..5b919cc --- /dev/null +++ b/tests/js/dom.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from "vitest" + +import { + all, + autoInit, + currentValue, + data, + escapeHtml, + flag, + isBlank, + isRtl, + once, + onReady, + picks, + select, + valueOf, +} from "../../resources/js/dom" +import { $, flush, html } from "./helpers" + +describe("dom helpers", () => { + it("marks elements once per feature", () => { + html(`
`) + const el = $("#a") + + expect(once(el, "select")).toBe(true) + expect(once(el, "select")).toBe(false) + expect(once(el, "dates")).toBe(true) + expect(el.getAttribute("data-forms-ready")).toBe("select dates") + }) + + it("finds elements including the root", () => { + html(`
`) + expect(all($("#root"), ".x")).toHaveLength(2) + expect(all(document, ".x")).toHaveLength(2) + }) + + it("ignores invalid selectors", () => { + expect(select("[[[")).toEqual([]) + expect(select("")).toEqual([]) + expect(picks("[[[", document.body)).toBe(false) + expect(picks(null, document.body)).toBe(false) + expect(picks("body", document.body)).toBe(true) + }) + + it("reads data attributes like jQuery", () => { + html( + `
`, + ) + const el = $("#a") + + expect(data(el, "t")).toBe(true) + expect(data(el, "f")).toBe(false) + expect(data(el, "n")).toBe(12) + expect(data(el, "null")).toBeNull() + expect(data(el, "j")).toEqual(["1", "2"]) + expect(data(el, "o")).toEqual({ a: 1 }) + expect(data(el, "bad")).toBe("[oops") + expect(data(el, "s")).toBe("ދިވެހި") + expect(data(el, "missing")).toBeUndefined() + expect(flag(el, "e")).toBe(false) + expect(flag(el, "t")).toBe(true) + }) + + it("escapes html", () => { + expect(escapeHtml(`'&'`)).toBe("<a href="x">'&'</a>") + expect(escapeHtml(null)).toBe("") + }) + + it("knows the direction", () => { + html(`
`) + expect(isRtl($("#a"))).toBe(true) + expect(isRtl($("#b"))).toBe(false) + expect(isRtl()).toBe(false) + }) + + it("reads values", () => { + html(` + + + + +
`) + + expect(valueOf($("#m"))).toEqual(["1", "2"]) + expect(valueOf($("#t"))).toBe("ހ") + expect(valueOf($("#d"))).toBe("") + expect(currentValue(select("[name=r]"))).toBe("b") + expect(currentValue(select("[name=c]"))).toEqual(["x"]) + expect(currentValue(select("#t"))).toBe("ހ") + expect(isBlank([])).toBe(true) + expect(isBlank("")).toBe(true) + expect(isBlank("0")).toBe(false) + }) + + it("runs when the DOM is ready", () => { + const fn = vi.fn() + onReady(fn) + expect(fn).toHaveBeenCalledOnce() + + const state = vi.spyOn(document, "readyState", "get").mockReturnValue("loading") + const later = vi.fn() + onReady(later) + expect(later).not.toHaveBeenCalled() + state.mockRestore() + document.dispatchEvent(new Event("DOMContentLoaded")) + expect(later).toHaveBeenCalledOnce() + }) + + it("initialises on load and again for added content", async () => { + document.documentElement.removeAttribute("data-forms-manual") + const init = vi.fn() + + autoInit(init, 0) + expect(init).toHaveBeenCalledTimes(1) + + document.body.appendChild(document.createTextNode("ނޯޑު")) + await flush() + expect(init).toHaveBeenCalledTimes(1) + + document.body.appendChild(document.createElement("div")) + await flush() + expect(init).toHaveBeenCalledTimes(2) + + document.documentElement.setAttribute("data-forms-manual", "") + }) + + it("does nothing in manual mode", () => { + const init = vi.fn() + autoInit(init) + expect(init).not.toHaveBeenCalled() + }) +}) diff --git a/tests/js/files.test.ts b/tests/js/files.test.ts new file mode 100644 index 0000000..957f5ae --- /dev/null +++ b/tests/js/files.test.ts @@ -0,0 +1,317 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { accepts, after, clear, init, remove, select, showError, upload } from "../../resources/js/files" +import { $, click, fire, flush, html, jsonResponse, mockFetch, setFiles } from "./helpers" + +const image = (name = "ފޮޓޯ.png", type = "image/png", size = 10) => new File(["x".repeat(size)], name, { type }) + +const fileInput = ` +
+ + + +
` + +const imageInput = ` +
+
+ + +
` + +const uploadInput = (form = `action="/users/1" method="POST"`, extra = "") => ` +
+ + ${extra} +
+
+ Upload file + + +
+
    +
    ` + +const file = () => $("#file") + +describe("file inputs", () => { + it("shows the chosen file's name", () => { + html(fileInput) + init() + init() + + setFiles(file(), [image("ލިޔުން.pdf", "application/pdf")]) + fire(file()) + + expect($("#root").classList.contains("fileinput-exists")).toBe(true) + expect($("#root").classList.contains("fileinput-new")).toBe(false) + expect(document.querySelectorAll(".fileinput-filename")[1]?.textContent).toBe("ލިޔުން.pdf") + expect($("#root").getAttribute("data-forms-ready")).toBe("files") + }) + + it("lists several files", () => { + html(fileInput) + setFiles(file(), [image("a.pdf"), image("b.pdf")]) + select($("#root"), file()) + expect($(".fileinput-filename").textContent).toBe("a.pdf, b.pdf") + }) + + it("clears when the picker is cancelled or dismissed", () => { + html(fileInput) + init() + + setFiles(file(), [image()]) + fire(file()) + setFiles(file(), []) + fire(file()) + expect($("#root").classList.contains("fileinput-new")).toBe(true) + + setFiles(file(), [image()]) + fire(file()) + expect(click($("#icon")).defaultPrevented).toBe(true) + expect($("#root").classList.contains("fileinput-new")).toBe(true) + expect($(".fileinput-filename").textContent).toBe("") + }) + + it("previews images", async () => { + html(imageInput) + init() + + setFiles(file(), [image()]) + fire(file()) + await vi.waitFor(() => expect($("#img").src).toMatch(/^data:image\/png;base64,/)) + + clear($("#root")) + expect($("#img").hasAttribute("src")).toBe(false) + }) + + it("doesn't preview other files", () => { + html(imageInput) + setFiles(file(), [image("a.pdf", "application/pdf")]) + select($("#root"), file()) + expect($("#img").hasAttribute("src")).toBe(false) + }) + + it("opens the picker from the preview", () => { + html(imageInput) + init() + const open = vi.spyOn(file(), "click") + click($("#img")) + expect(open).toHaveBeenCalledOnce() + }) + + it("ignores clicks and changes elsewhere", () => { + html(`

    `) + init() + fire($("#loose")) + fire($("#text")) + click($("#p")) + document.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) +}) + +describe("accepted types", () => { + it("matches MIME types, wildcards and extensions", () => { + expect(accepts("image/*", image())).toBe(true) + expect(accepts("image/png, application/pdf", image("a.pdf", "application/pdf"))).toBe(true) + expect(accepts(".PDF", image("ލިޔުން.pdf", ""))).toBe(true) + expect(accepts("image/jpeg", image())).toBe(false) + expect(accepts("", image())).toBe(true) + }) +}) + +describe("upload inputs", () => { + beforeEach(() => { + vi.spyOn(after, "success").mockImplementation(() => undefined) + }) + + it("uploads the file to the form's action, then reloads", async () => { + html(uploadInput()) + init() + const fetch = mockFetch(jsonResponse({})) + + setFiles(file(), [image()]) + fire(file()) + expect($("#upload").textContent).toContain("Uploading...") + expect($("#upload").getAttribute("aria-busy")).toBe("true") + await flush() + + const [url, request] = fetch.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe("/users/1") + expect(request.method).toBe("POST") + expect((request.body as FormData).get("avatar")).toBeInstanceOf(File) + expect((request.body as FormData).has("_method")).toBe(false) + expect(request.headers).toMatchObject({ "X-CSRF-TOKEN": "secret", Accept: "application/json" }) + expect(after.success).toHaveBeenCalledOnce() + }) + + it("spoofs the form's method", async () => { + html(uploadInput(`action="/users/1" method="POST"`, ``)) + const fetch = mockFetch(jsonResponse({})) + + setFiles(file(), [image()]) + await upload($("#root"), file()) + const request = (fetch.mock.calls[0] as unknown as [string, RequestInit])[1] + expect((request.body as FormData).get("_method")).toBe("PATCH") + }) + + it("uses the page URL and the csrf meta tag when the form has neither", async () => { + html( + `${uploadInput("").replace('', "")}`, + ) + const fetch = mockFetch(jsonResponse({})) + + setFiles(file(), [image()]) + await upload($("#root"), file()) + const [url, request] = fetch.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(window.location.href) + expect(request.headers).toMatchObject({ "X-CSRF-TOKEN": "meta-token" }) + }) + + it("rejects files that are too large", async () => { + document.documentElement.setAttribute("lang", "dv") + html(uploadInput()) + const fetch = mockFetch() + + setFiles(file(), [image("big.png", "image/png", 2 * 1024 * 1024)]) + await upload($("#root"), file()) + + expect(fetch).not.toHaveBeenCalled() + expect($("#errors").textContent).toBe("ފައިލްގެ ސައިޒު 1 MB އަށްވުރެ ބޮޑުވެގެން ނުވާނެ") + expect(file().getAttribute("aria-invalid")).toBe("true") + expect($("#root").classList.contains("is-invalid")).toBe(true) + }) + + it("defaults the maximum to 2 MB", async () => { + html(uploadInput()) + file().removeAttribute("data-max-file-size") + mockFetch() + setFiles(file(), [image("big.png", "image/png", 3 * 1024 * 1024)]) + await upload($("#root"), file()) + expect($("#errors").textContent).toBe("File size cannot be greater than 2 MB") + }) + + it("rejects other types", async () => { + html(uploadInput()) + mockFetch() + setFiles(file(), [image("a.gif", "image/gif")]) + await upload($("#root"), file()) + expect($("#errors").textContent).toBe("Only image/png, .pdf files are allowed.") + }) + + it("shows validation errors and restores the button", async () => { + html(uploadInput()) + mockFetch(jsonResponse({ message: "x", errors: { avatar: ["ފޮޓޯ ބޮޑުވެއްޖެ"] } }, 422)) + + setFiles(file(), [image()]) + await upload($("#root"), file()) + expect($("#errors").textContent).toBe("ފޮޓޯ ބޮޑުވެއްޖެ") + expect($("#upload").textContent).toBe("Upload file") + expect($("#upload").hasAttribute("aria-busy")).toBe(false) + expect(after.success).not.toHaveBeenCalled() + + // A later valid choice clears the error first. + mockFetch(jsonResponse({})) + await upload($("#root"), file()) + expect($("#errors").textContent).toBe("") + }) + + it("falls back to the message or a generic error", async () => { + html(uploadInput()) + setFiles(file(), [image()]) + + mockFetch(jsonResponse({ message: "ގޯސް" }, 422)) + await upload($("#root"), file()) + expect($("#errors").textContent).toBe("ގޯސް") + + mockFetch(new Response("not json", { status: 422 })) + await upload($("#root"), file()) + expect($("#errors").textContent).toBe("An error occurred while uploading the file.") + + mockFetch(jsonResponse({}, 500)) + await upload($("#root"), file()) + expect($("#errors").textContent).toBe("An error occurred while uploading the file.") + + mockFetch(new Error("offline")) + await upload($("#root"), file()) + expect($("#errors").textContent).toBe("An error occurred while uploading the file.") + }) + + it("alerts when the field has no error list", () => { + html(``) + const alert = vi.spyOn(window, "alert").mockImplementation(() => undefined) + showError(file(), "ގޯސް") + expect(alert).toHaveBeenCalledWith("ގޯސް") + }) + + it("ignores changes while uploading, without files or outside a form", async () => { + html(uploadInput()) + let release: (r: Response) => void = () => undefined + const fetch = mockFetch(() => new Promise((resolve) => (release = resolve))) + init() + + setFiles(file(), [image()]) + fire(file()) + fire(file()) + click($("#trigger")) + await remove($("#root"), $("#dismiss")) + expect(fetch).toHaveBeenCalledOnce() + + release(jsonResponse({})) + await flush() + + setFiles(file(), []) + await upload($("#root"), file()) + + html(uploadInput().replace("", "")) + setFiles(file(), [image()]) + await upload($("#root"), file()) + await remove($("#root"), $("#dismiss")) + expect(fetch).toHaveBeenCalledOnce() + }) + + it("opens the picker from the trigger", () => { + html(uploadInput()) + init() + const open = vi.spyOn(file(), "click") + click($("#trigger")) + expect(open).toHaveBeenCalledOnce() + }) + + it("removes the file on the server after confirming", async () => { + document.documentElement.setAttribute("lang", "dv") + html(uploadInput(`action="/users/1" method="POST"`, ``)) + init() + const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(false).mockReturnValueOnce(true) + const fetch = mockFetch(jsonResponse({})) + + click($("#dismiss")) + expect(fetch).not.toHaveBeenCalled() + expect(confirm).toHaveBeenCalledWith("ކަށަވަރުތަ؟\nމި ފައިލް ފޮހެލުމަށްފަހު އަނބުރާ ނުގެނެވޭނެ!") + + click($("#dismiss")) + expect($("#dismiss").textContent).toContain("ފޮހެލަނީ…") + await flush() + + const request = (fetch.mock.calls[0] as unknown as [string, RequestInit])[1] + expect(String(request.body)).toBe("avatar=&_method=PUT") + expect(after.success).toHaveBeenCalledOnce() + }) + + it("shows remove errors", async () => { + html(uploadInput()) + vi.spyOn(window, "confirm").mockReturnValue(true) + mockFetch(jsonResponse({}, 500)) + + await remove($("#root"), $("#dismiss")) + expect($("#errors").textContent).toBe("An error occurred while removing the file.") + expect($("#dismiss").textContent).toBe("Remove") + }) + + it("does nothing to remove without a file input", async () => { + html(`
    `) + const confirm = vi.spyOn(window, "confirm") + await remove($("#root"), $("#dismiss")) + expect(confirm).not.toHaveBeenCalled() + }) +}) diff --git a/tests/js/forms.test.ts b/tests/js/forms.test.ts new file mode 100644 index 0000000..a9ee8c7 --- /dev/null +++ b/tests/js/forms.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest" + +import * as forms from "../../resources/js/forms" +import { $, fire, html } from "./helpers" + +describe("forms entry", () => { + it("initialises every core behaviour", () => { + html(` + + + + + `) + + forms.init() + + expect($("#all").checked).toBe(true) + expect($("#t").getAttribute("data-forms-ready")).toBe("auto-resize") + expect($("#th").getAttribute("data-forms-ready")).toBe("thaana") + expect($("#d").getAttribute("data-state")).toBe("closed") + + $("#c").checked = true + fire($("#c")) + expect($("#a").disabled).toBe(true) + }) + + it("exports the modules", () => { + expect(Object.keys(forms).sort()).toEqual( + ["conditional", "init", "inputs", "selectAll", "sortable", "submit", "thaana", "widgets"].sort(), + ) + }) +}) diff --git a/tests/js/helpers.ts b/tests/js/helpers.ts new file mode 100644 index 0000000..59e0790 --- /dev/null +++ b/tests/js/helpers.ts @@ -0,0 +1,65 @@ +import { vi } from "vitest" + +/** Puts markup in the page and returns the body. */ +export function html(markup: string): HTMLElement { + document.body.innerHTML = markup + return document.body +} + +export function $(selector: string): T { + const el = document.querySelector(selector) + if (!el) throw new Error(`No element matches ${selector}`) + return el +} + +/** Fires a bubbling event, as the browser does. */ +export function fire(el: EventTarget, type = "change"): void { + el.dispatchEvent(new Event(type, { bubbles: true })) +} + +export function click(el: Element, init: MouseEventInit = {}): MouseEvent { + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...init }) + el.dispatchEvent(event) + return event +} + +export function key(el: Element, keyName: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { key: keyName, bubbles: true, cancelable: true }) + el.dispatchEvent(event) + return event +} + +/** Lets promises, mutation observers and zero timeouts run. */ +export async function flush(times = 3): Promise { + for (let i = 0; i < times; i++) { + if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(1) + else await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +export function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }) +} + +/** Stubs fetch with responses (or errors) in order, and returns the mock. */ +export function mockFetch(...responses: (Response | Error | (() => Response | Promise))[]) { + const fetch = vi.fn(async () => { + const next = responses.shift() + if (next === undefined) throw new Error("Unexpected fetch") + if (next instanceof Error) throw next + return typeof next === "function" ? next() : next + }) + vi.stubGlobal("fetch", fetch) + return fetch +} + +/** The URL of the nth fetch call, decoded for readable assertions. */ +export function calledUrl(fetch: ReturnType, index = 0): string { + const call = fetch.mock.calls[index] as unknown as [string] + return decodeURIComponent(call[0]) +} + +/** Sets the files of a file input. */ +export function setFiles(input: HTMLInputElement, files: File[]): void { + Object.defineProperty(input, "files", { configurable: true, value: files }) +} diff --git a/tests/js/i18n.test.ts b/tests/js/i18n.test.ts new file mode 100644 index 0000000..9c25eb9 --- /dev/null +++ b/tests/js/i18n.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest" + +import { + __, + addLines, + getFallback, + getLocale, + load, + makeReplacements, + normalizeLocale, + setFallback, + setLocale, + trans, +} from "../../resources/js/i18n" +import "../../resources/js/lang" + +describe("i18n", () => { + it("reads the locale from ", () => { + document.documentElement.setAttribute("lang", "dv-MV") + expect(getLocale()).toBe("dv_MV") + + document.documentElement.removeAttribute("lang") + expect(getLocale()).toBe("en") + }) + + it("normalizes locales", () => { + expect(normalizeLocale("EN-gb")).toBe("en_GB") + expect(normalizeLocale("dv")).toBe("dv") + }) + + it("translates the package's strings in the page's locale", () => { + document.documentElement.setAttribute("lang", "dv") + expect(__("forms::strings.loading")).toBe("ލޯޑުވަނީ…") + + document.documentElement.setAttribute("lang", "en") + expect(__("forms::strings.loading")).toBe("Loading...") + }) + + it("falls back from a regional locale to its language and then to the fallback", () => { + document.documentElement.setAttribute("lang", "dv-MV") + expect(__("forms::strings.clear")).toBe("ފޮހެލާ") + + document.documentElement.setAttribute("lang", "fr") + expect(__("forms::strings.clear")).toBe("Clear") + }) + + it("takes an explicit locale", () => { + expect(__("forms::strings.close", {}, "dv")).toBe("ބަންދުކުރޭ") + }) + + it("returns the key for a missing line", () => { + expect(__("forms::strings.missing")).toBe("forms::strings.missing") + expect(__("nogroup")).toBe("nogroup") + }) + + it("makes Laravel's replacements", () => { + expect(__("forms::strings.file_too_large", { size: 2 })).toBe("File size cannot be greater than 2 MB") + expect(makeReplacements(":name, :Name, :NAME", { name: "ali" })).toBe("ali, Ali, ALI") + expect(makeReplacements(":a :ab", { a: "1", ab: "2" })).toBe("1 2") + expect(makeReplacements("no placeholders")).toBe("no placeholders") + }) + + it("adds lines like Lang::addLines()", () => { + addLines({ "messages.hello": "ހެލޯ :name", invalid: "x" }, "dv") + addLines({ "strings.extra": "އިތުރު" }, "dv", "forms") + + expect(__("messages.hello", { name: "އަލީ" }, "dv")).toBe("ހެލޯ އަލީ") + expect(__("forms::strings.extra", {}, "dv")).toBe("އިތުރު") + expect(trans("forms::strings.extra", {}, "dv")).toBe("އިތުރު") + }) + + it("loads groups and overrides the locale and fallback", () => { + load({ "app::labels": { name: "ނަން" } }, { locale: "DV" }) + setLocale("dv") + expect(__("app::labels.name")).toBe("ނަން") + setLocale(undefined) + + setFallback("dv") + expect(getFallback()).toBe("dv") + document.documentElement.setAttribute("lang", "fr") + expect(__("app::labels.name")).toBe("ނަން") + setFallback("en") + }) +}) diff --git a/tests/js/inputs.test.ts b/tests/js/inputs.test.ts new file mode 100644 index 0000000..2c1ed1d --- /dev/null +++ b/tests/js/inputs.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest" + +import { clearDate, init, resize, round } from "../../resources/js/inputs" +import { $, click, fire, html } from "./helpers" + +describe("auto resize", () => { + it("grows with the content", () => { + html(``) + const textarea = $("#t") + Object.defineProperty(textarea, "scrollHeight", { configurable: true, get: () => 80 }) + + init() + expect(textarea.style.height).toBe("84px") + expect(textarea.style.overflowY).toBe("hidden") + + Object.defineProperty(textarea, "scrollHeight", { configurable: true, get: () => 120 }) + textarea.value = "ދިގު ލިޔުމެއް\nދެވަނަ ލައިން" + fire(textarea, "input") + expect(textarea.style.height).toBe("124px") + }) + + it("leaves out the padding for content-box textareas", () => { + html(``) + const textarea = $("#t") + Object.defineProperty(textarea, "scrollHeight", { configurable: true, get: () => 50 }) + + resize(textarea) + expect(textarea.style.height).toBe("40px") + }) + + it("ignores other textareas", () => { + html(``) + init() + fire($("#t"), "input") + expect($("#t").style.height).toBe("") + }) +}) + +describe("decimals", () => { + it("rounds on change and blur", () => { + html(``) + init() + + const input = $("#n") + input.value = "3.14159" + fire(input) + expect(input.value).toBe("3.14") + + input.value = "2" + fire(input, "focusout") + expect(input.value).toBe("2.00") + + $("#z").value = "2.5" + fire($("#z")) + expect($("#z").value).toBe("3") + }) + + it("leaves empty, invalid and unconfigured values alone", () => { + html(``) + const a = $("#a") + const b = $("#b") + const c = $("#c") + b.value = "1.234" + c.value = "ދިވެހި" + + round(a) + round(b) + round(c) + expect([a.value, b.value, c.value]).toEqual(["", "1.234", "ދިވެހި"]) + }) +}) + +describe("date clear", () => { + it("clears the input and fires change", () => { + html(``) + init() + const change = vi.fn() + $("#d").addEventListener("change", change) + + expect(click($("#x")).defaultPrevented).toBe(true) + expect($("#d").value).toBe("") + expect(change).toHaveBeenCalledOnce() + }) + + it("clears through flatpickr when there is one", () => { + html(``) + init() + const clear = vi.fn() + Object.assign($("#d"), { _flatpickr: { clear } }) + + click($("#icon")) + expect(clear).toHaveBeenCalledWith(true) + }) + + it("skips disabled inputs and bad targets", () => { + html(` + `) + init() + click($("#x")) + click($("#y")) + click($("#z")) + expect($("#d").value).toBe("2024-01-02") + + const input = document.createElement("input") + input.readOnly = true + input.value = "x" + clearDate(input) + expect(input.value).toBe("x") + }) +}) diff --git a/tests/js/remote.test.ts b/tests/js/remote.test.ts new file mode 100644 index 0000000..473b3b6 --- /dev/null +++ b/tests/js/remote.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" + +import { getJson, itemsOf, toOptions, withQuery } from "../../resources/js/remote" +import { jsonResponse, mockFetch } from "./helpers" + +describe("remote", () => { + it("builds query strings like jQuery's $.param()", () => { + const url = withQuery("/api/islands", { + append: "", + page: 2, + filter: { search: "މާލެ", atoll: ["1", "2"] }, + none: null, + }) + + expect(decodeURIComponent(url)).toBe( + "/api/islands?append=&page=2&filter[search]=މާލެ&filter[atoll][]=1&filter[atoll][]=2&none=", + ) + expect(withQuery("/api?x=1", { y: 2 })).toBe("/api?x=1&y=2") + expect(withQuery("/api", {})).toBe("/api") + }) + + it("reads plain arrays and paginated responses", () => { + expect(itemsOf([{ id: 1 }])).toEqual({ items: [{ id: 1 }], more: false }) + expect(itemsOf({ data: [{ id: 1 }], next_page_url: "/?page=2" })).toEqual({ items: [{ id: 1 }], more: true }) + expect(itemsOf({ data: [], next_page_url: null })).toEqual({ items: [], more: false }) + expect(itemsOf({ nope: true })).toEqual({ items: [], more: false }) + expect(itemsOf("x")).toEqual({ items: [], more: false }) + }) + + it("maps items to options", () => { + expect(toOptions([{ code: 7, title: "ހުޅުމާލެ" }, {}], "code", "title")).toEqual([ + { value: "7", text: "ހުޅުމާލެ" }, + { value: "", text: "" }, + ]) + }) + + it("gets JSON with Laravel's ajax headers", async () => { + const fetch = mockFetch(jsonResponse([{ id: 1 }]), jsonResponse({}, 500)) + + await expect(getJson("/api")).resolves.toEqual([{ id: 1 }]) + expect(fetch).toHaveBeenCalledWith("/api", { + headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" }, + credentials: "same-origin", + signal: undefined, + }) + + await expect(getJson("/api")).rejects.toThrow("HTTP 500") + }) +}) diff --git a/tests/js/select-all.test.ts b/tests/js/select-all.test.ts new file mode 100644 index 0000000..f1bb3b8 --- /dev/null +++ b/tests/js/select-all.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest" + +import { init } from "../../resources/js/select-all" +import { $, fire, html } from "./helpers" + +const box = (selector: string) => $(selector) + +const table = ` + + + + + + ` + +describe("select all", () => { + it("checks and unchecks every enabled row", () => { + html(table) + init() + + box("#all").checked = true + fire(box("#all")) + + expect(box("#r1").checked).toBe(true) + expect(box("#r2").checked).toBe(true) + expect(box("#r3").checked).toBe(false) + expect(box("#other").checked).toBe(false) + expect(box("#all-resp").checked).toBe(true) + + box("#all").checked = false + fire(box("#all")) + expect(box("#r1").checked).toBe(false) + }) + + it("is indeterminate when some rows are checked", () => { + html(table) + init() + + box("#r1").checked = true + fire(box("#r1")) + expect(box("#all").checked).toBe(false) + expect(box("#all").indeterminate).toBe(true) + + box("#r2").checked = true + fire(box("#r2")) + expect(box("#all").checked).toBe(true) + expect(box("#all").indeterminate).toBe(false) + }) + + it("syncs on init", () => { + html(table.replace('id="r1"', 'id="r1" checked')) + init() + expect(box("#all").indeterminate).toBe(true) + }) + + it("ignores other changes", () => { + html(``) + init() + fire(box("#text")) + fire(box("#plain")) + }) +}) diff --git a/tests/js/select.test.ts b/tests/js/select.test.ts new file mode 100644 index 0000000..4fd2347 --- /dev/null +++ b/tests/js/select.test.ts @@ -0,0 +1,472 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + ajaxParams, + enhance, + init, + instanceOf, + loadChildren, + refresh, + reset, + resolveUrl, + setOptions, + settingsFor, +} from "../../resources/js/select" +import { $, calledUrl, fire, flush, html, jsonResponse, mockFetch } from "./helpers" + +const sel = (selector: string) => $(selector) + +const islands = [ + { id: 1, name: "މާލެ" }, + { id: 2, name: "ހުޅުމާލެ" }, +] + +describe("enhanced selects", () => { + it("enhances select2 selects once, keeping the wrapper's own classes", () => { + html(` +
    + +
    + `) + + init() + init() + + const ts = instanceOf(sel("#s"))! + expect(ts).toBeDefined() + expect(instanceOf(sel("#plain"))).toBeUndefined() + expect(ts.wrapper.classList.contains("h-8")).toBe(false) + expect(ts.wrapper.classList.contains("is-invalid")).toBe(true) + expect(ts.wrapper.classList.contains("select2-basic")).toBe(true) + expect(ts.items).toEqual(["1"]) + expect(ts.settings.placeholder).toBe("ރަށް") + expect(ts.settings.maxOptions).toBeNull() + expect(document.querySelectorAll(".ts-wrapper")).toHaveLength(1) + expect(enhance(sel("#s"))).toBe(ts) + }) + + it("titles the clear button in the page's language", () => { + document.documentElement.setAttribute("lang", "dv") + html(``) + const ts = enhance(sel("#s")) + + expect(ts.wrapper.querySelector(".clear-button")?.getAttribute("title")).toBe("ފޮހެލާ") + expect(ts.render("no_results", {})?.textContent).toBe("އެއްވެސް ނަތީޖާއެއް ނުފެނުނު") + expect(ts.render("loading", {})?.textContent).toBe("ލޯޑުވަނީ…") + }) + + it("hides the search box", () => { + html(``) + const ts = enhance(sel("#s")) + + expect(ts.control.querySelector("input")).toBeNull() + expect(ts.settings.allowEmptyOption).toBe(true) + }) + + it("lets users add tags", () => { + html(``) + const ts = enhance(sel("#s")) + + expect(ts.settings.create).toBe(true) + expect(ts.wrapper.classList.contains("plugin-remove_button")).toBe(true) + expect(ts.render("option_create", { input: "<ދ>" })?.innerHTML).toBe('Add "<ދ>"') + }) + + it("renders icons", () => { + html(``) + const ts = enhance(sel("#s")) + + expect(ts.wrapper.querySelector(".item i")?.className).toBe("fa fa-home select2-icon") + expect(ts.render("option", { value: "", text: "-" })?.textContent).toBe("އައިކަން") + }) + + it("renders icons without a placeholder", () => { + html(``) + const ts = enhance(sel("#s")) + expect(ts.render("option", { value: "", text: "-" })?.textContent).toBe("-") + }) + + it("puts the dropdown in the given parent, when it exists", () => { + html(` + `) + expect(enhance(sel("#s")).dropdown.parentElement).toBe($("#m")) + expect(settingsFor(sel("#t")).dropdownParent).toBeUndefined() + }) + + it("follows the select's disabled state", async () => { + html(``) + const ts = enhance(sel("#s")) + + sel("#s").disabled = true + await flush() + expect(ts.isDisabled).toBe(true) + + sel("#s").disabled = false + await flush() + expect(ts.isDisabled).toBe(false) + }) + + it("re-reads changed options", () => { + html(``) + const ts = enhance(sel("#s")) + + setOptions( + sel("#s"), + [ + { value: "3", text: "ތިން" }, + { value: "4", text: "ހަތަރެއް" }, + ], + "4", + ) + expect(Object.keys(ts.options)).toEqual(["3", "4"]) + expect(ts.items).toEqual(["4"]) + + sel("#s").disabled = true + refresh(sel("#s")) + expect(ts.isDisabled).toBe(true) + + refresh(document.createElement("select")) + }) + + it("keeps an empty placeholder option when replacing options", () => { + html(` + `) + + setOptions(sel("#s"), [{ value: "2", text: "ދެ" }]) + expect(sel("#s").options).toHaveLength(2) + expect(sel("#s").value).toBe("") + + setOptions( + sel("#m"), + [ + { value: "2", text: "ދެ" }, + { value: "3", text: "ތިން" }, + ], + ["2", "3"], + ) + expect(Array.from(sel("#m").selectedOptions).map((o) => o.value)).toEqual(["2", "3"]) + }) +}) + +/** Tom Select debounces loads by loadThrottle (250ms). */ +async function settle(): Promise { + await vi.advanceTimersByTimeAsync(300) +} + +describe("ajax selects", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + const ajax = (attrs = "") => + html(``) + + it("loads pages of results", async () => { + ajax(`data-name-field="title" data-id-field="code" data-per-page="10"`) + const fetch = mockFetch( + jsonResponse({ data: [{ code: 1, title: "މާލެ" }], next_page_url: "/api/islands?page=2" }), + jsonResponse({ data: [{ code: 2, title: "ހުޅުމާލެ" }], next_page_url: null }), + ) + const ts = enhance(sel("#s")) + + ts.load("މާ") + await settle() + + expect(calledUrl(fetch)).toBe( + "/api/islands?append=&include=&page=1&fields=code,title&per_page=10&filter[search]=މާ", + ) + expect(ts.options["1"]?.text).toBe("މާލެ") + expect(ts.options["9"]?.text).toBe("ތިނަދޫ") + + ts.load("މާ") + await settle() + expect(calledUrl(fetch, 1)).toContain("page=2") + expect(ts.options["2"]?.text).toBe("ހުޅުމާލެ") + }) + + it("reads plain arrays", async () => { + ajax() + mockFetch(jsonResponse(islands)) + const ts = enhance(sel("#s")) + + ts.load("ހ") + await settle() + expect(ts.options["2"]?.text).toBe("ހުޅުމާލެ") + }) + + it("shows an error when loading fails", async () => { + document.documentElement.setAttribute("lang", "dv") + ajax() + mockFetch(new Error("offline")) + const ts = enhance(sel("#s")) + + ts.load("ހ") + await settle() + expect(ts.render("no_results", {})?.textContent).toBe("އޮޕްޝަންތައް ލޯޑެއް ނުކުރެވުނު") + }) + + it("waits for the minimum input length", () => { + ajax() + const ts = enhance(sel("#s")) + expect(ts.settings.shouldLoad("")).toBe(false) + expect(ts.settings.shouldLoad("ހ")).toBe(true) + expect(ts.settings.preload).toBe(false) + }) + + it("loads as soon as it opens with a minimum input length of 0", () => { + ajax(`data-minimum-input-length="0"`) + const settings = settingsFor(sel("#s")) + expect(settings.preload).toBe("focus") + expect((settings.shouldLoad as (q: string) => boolean)("")).toBe(true) + + sel("#s").setAttribute("data-minimum-input-length", "x") + expect((settingsFor(sel("#s")).shouldLoad as (q: string) => boolean)("")).toBe(false) + }) + + it("scores every loaded result", () => { + ajax() + const settings = settingsFor(sel("#s")) as { score: () => () => number } + expect(settings.score()()).toBe(1) + }) + + it("does nothing without a URL", async () => { + html(``) + const fetch = mockFetch() + enhance(sel("#s")).load("ހ") + await settle() + expect(fetch).not.toHaveBeenCalled() + }) + + it("picks the URL for data-selected-url", () => { + html( + ``, + ) + expect(resolveUrl(sel("#s"))).toBe("/api/atolls") + + sel("#s").setAttribute("data-selected-url", "nope") + expect(resolveUrl(sel("#s"))).toBe("/api/islands") + + sel("#s").setAttribute("data-select-ajax-url", "{}") + expect(resolveUrl(sel("#s"))).toBe("") + + sel("#s").setAttribute("data-select-ajax-url", "[1]") + expect(resolveUrl(sel("#s"))).toBe("") + }) + + it("filters by a parent select", () => { + html(` + + `) + + expect(resolveUrl(sel("#s"))).toBe("/api/atolls/k/islands") + expect(ajaxParams(sel("#s"), 1, "")).toEqual({ + append: "", + include: "", + page: 1, + fields: "id,name", + filter: { atoll: "k" }, + }) + }) + + it("is disabled until the parent has a value, and resets when it changes", async () => { + html(` + + + `) + + init() + expect(sel("#s").disabled).toBe(true) + const changed = vi.fn() + sel("#s").addEventListener("change", changed) + + sel("#atoll").value = "k" + fire(sel("#atoll")) + await flush() + expect(sel("#s").disabled).toBe(false) + expect(sel("#s").options).toHaveLength(0) + expect(instanceOf(sel("#s"))!.isDisabled).toBe(false) + expect(changed).toHaveBeenCalledOnce() + + fire(sel("#atoll")) + expect(changed).toHaveBeenCalledOnce() + + sel("#atoll").value = "" + fire(sel("#atoll")) + expect(sel("#s").disabled).toBe(true) + }) + + it("resets plain selects too", () => { + html(``) + reset(sel("#s")) + expect(sel("#s").options).toHaveLength(0) + }) +}) + +describe("cascades", () => { + const cascade = (childAttrs = "", parentAttrs = "") => + html(` + + + `) + + it("loads the child's options for data-first and keeps its value", async () => { + cascade("", "data-first") + const fetch = mockFetch(jsonResponse(islands)) + + init() + expect(sel("#island").disabled).toBe(true) + expect(sel("#island").options[0]?.textContent).toBe("Loading...") + + await flush() + expect(calledUrl(fetch)).toBe("/api/atolls/k/islands?fields=id,name&per_page=-1&include=&append=&filter[atoll]=k") + expect(Array.from(sel("#island").options).map((o) => o.textContent)).toEqual(["މާލެ", "ހުޅުމާލެ"]) + expect(sel("#island").value).toBe("2") + expect(sel("#island").disabled).toBe(false) + }) + + it("keeps the child's placeholder across loads", async () => { + cascade() + sel("#island").insertAdjacentHTML("afterbegin", ``) + mockFetch(jsonResponse(islands)) + + await loadChildren(sel("#atoll")) + expect(Array.from(sel("#island").options).map((o) => o.textContent)).toEqual(["ރަށެއް", "މާލެ", "ހުޅުމާލެ"]) + }) + + it("doesn't auto-load with .no-auto-trigger", () => { + cascade("", 'data-first class="no-auto-trigger"') + const fetch = mockFetch() + init() + expect(fetch).not.toHaveBeenCalled() + }) + + it("loads on change, into an enhanced child", async () => { + cascade('class="select2-basic"') + mockFetch(jsonResponse({ data: islands })) + init() + + fire(sel("#atoll")) + await flush() + expect(Object.keys(instanceOf(sel("#island"))!.options)).toEqual(["1", "2"]) + }) + + it("shows the error text when loading fails", async () => { + document.documentElement.setAttribute("lang", "dv") + cascade() + mockFetch(jsonResponse({}, 500)) + + await loadChildren(sel("#atoll")) + expect(sel("#island").options[0]?.textContent).toBe("އޮޕްޝަންތައް ލޯޑެއް ނުކުރެވުނު") + }) + + it("swaps in the fallback when there are no options", async () => { + cascade('data-fallback="#fallback"') + mockFetch(jsonResponse([]), jsonResponse(islands)) + + await loadChildren(sel("#atoll")) + expect(sel("#island").hidden).toBe(true) + expect(sel("#island").disabled).toBe(true) + expect($("#fallback").hidden).toBe(false) + expect($("#fallback").disabled).toBe(false) + + await loadChildren(sel("#atoll")) + expect(sel("#island").hidden).toBe(false) + expect($("#fallback").hidden).toBe(true) + expect($("#fallback").disabled).toBe(true) + }) + + it("disables an empty child with data-disable-child", async () => { + cascade("", 'data-disable-child="true"') + mockFetch(jsonResponse([])) + await loadChildren(sel("#atoll")) + expect(sel("#island").disabled).toBe(true) + }) + + it("empties and disables the child when the parent is cleared", async () => { + cascade('data-fallback="#fallback"', 'data-fallback="#parent-fallback"') + document.body.insertAdjacentHTML("beforeend", ``) + sel("#atoll").value = "" + const changed = vi.fn() + sel("#island").addEventListener("change", changed) + + await loadChildren(sel("#atoll")) + expect(sel("#island").options).toHaveLength(0) + expect(sel("#island").disabled).toBe(true) + expect($("#fallback").hidden).toBe(false) + expect($("#fallback").disabled).toBe(false) + expect(changed).toHaveBeenCalledOnce() + }) + + it("disables the fallback when the parent's fallback is disabled", async () => { + cascade('data-fallback="#fallback"') + sel("#atoll").value = "" + await loadChildren(sel("#atoll")) + expect($("#fallback").disabled).toBe(true) + }) + + it("ignores a response that a newer request replaced", async () => { + cascade() + let release: (r: Response) => void = () => undefined + mockFetch(() => new Promise((resolve) => (release = resolve)), jsonResponse([{ id: 5, name: "ދ" }])) + + const first = loadChildren(sel("#atoll")) + await flush() + await loadChildren(sel("#atoll")) + release(jsonResponse(islands)) + await first + + expect(Array.from(sel("#island").options).map((o) => o.value)).toEqual(["5"]) + }) + + it("points an ajax child at the URL for the value", async () => { + html(` + + `) + + init() + expect(sel("#target").disabled).toBe(false) + expect(sel("#target").value).toBe("1") + + sel("#type").value = "atoll" + fire(sel("#type")) + expect(sel("#target").getAttribute("data-selected-url")).toBe("atoll") + expect(sel("#target").options).toHaveLength(0) + expect(resolveUrl(sel("#target"))).toBe("/api/atolls") + + sel("#type").value = "" + fire(sel("#type")) + expect(sel("#target").disabled).toBe(true) + expect(sel("#target").hasAttribute("data-selected-url")).toBe(false) + await flush() + expect(instanceOf(sel("#target"))!.isDisabled).toBe(true) + }) + + it("handles multiple parents", async () => { + html(` + + + `) + const fetch = mockFetch(jsonResponse([])) + await loadChildren(sel("#p")) + expect(calledUrl(fetch)).toContain("/api/1,2") + + init() + expect(sel("#a").getAttribute("data-selected-url")).toBe("1,2") + }) + + it("ignores change events from non-elements", () => { + init() + document.dispatchEvent(new Event("change")) + }) +}) diff --git a/tests/js/setup.ts b/tests/js/setup.ts new file mode 100644 index 0000000..00fc8be --- /dev/null +++ b/tests/js/setup.ts @@ -0,0 +1,13 @@ +import { afterEach, vi } from "vitest" + +// The modules enhance the page by themselves when loaded; tests call init() instead. +document.documentElement.setAttribute("data-forms-manual", "") + +afterEach(() => { + document.body.replaceChildren() + document.documentElement.setAttribute("lang", "en") + document.documentElement.removeAttribute("dir") + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() +}) diff --git a/tests/js/sortable.test.ts b/tests/js/sortable.test.ts new file mode 100644 index 0000000..5d693c9 --- /dev/null +++ b/tests/js/sortable.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { init, sort } from "../../resources/js/sortable" +import { $, click, html, key } from "./helpers" + +const page = (form = ``) => ` +
    ${form}
    + + + + + + + +
    ނަންތާރީޚުބާވަތް
    ` + +const value = (name: string) => $("#filter").querySelector(`[name="${name}"]`)?.value + +describe("sortable tables", () => { + let submit: ReturnType + + beforeEach(() => { + submit = vi.fn() + HTMLFormElement.prototype.requestSubmit = submit as unknown as HTMLFormElement["requestSubmit"] + }) + + afterEach(() => { + delete (HTMLFormElement.prototype as Partial).requestSubmit + }) + + it("sorts ascending by a new field and submits the form", () => { + html(page()) + init() + + expect(click($("#name")).defaultPrevented).toBe(true) + expect(value("orderby")).toBe("name") + expect(value("order")).toBe("ASC") + expect(submit).toHaveBeenCalledOnce() + + expect($("#name").className).toBe("sorting_asc") + expect($("#name").getAttribute("aria-sort")).toBe("ascending") + expect($("#date").className).toBe("sorting") + expect($("#date").getAttribute("aria-sort")).toBe("none") + }) + + it("toggles an ascending field to descending", () => { + html(page()) + init() + + click($("#date")) + expect(value("orderby")).toBe("created_at") + expect(value("order")).toBe("DESC") + expect($("#date").className).toBe("sorting_desc") + expect($("#date").getAttribute("aria-sort")).toBe("descending") + + click($("#date")) + expect(value("order")).toBe("ASC") + }) + + it("adds the hidden inputs the form is missing", () => { + html(page("")) + init() + click($("#name")) + expect(value("orderby")).toBe("name") + expect(value("order")).toBe("ASC") + expect($("#filter").querySelectorAll('input[type="hidden"]')).toHaveLength(2) + }) + + it("makes headings focusable and sorts with the keyboard", () => { + html(page()) + init() + init() + + expect($("#name").tabIndex).toBe(0) + expect($("#name").getAttribute("aria-sort")).toBe("none") + expect($("#date").getAttribute("aria-sort")).toBe("ascending") + expect($("#plain").hasAttribute("tabindex")).toBe(false) + + expect(key($("#name"), "Enter").defaultPrevented).toBe(true) + expect(value("orderby")).toBe("name") + key($("#date"), " ") + expect(value("orderby")).toBe("created_at") + expect(key($("#name"), "a").defaultPrevented).toBe(false) + expect(key($("#plain"), "Enter").defaultPrevented).toBe(false) + expect(submit).toHaveBeenCalledTimes(2) + }) + + it("reads sorting_desc headings", () => { + html(page().replace('class="sorting_asc"', 'class="sorting_desc"')) + init() + expect($("#date").getAttribute("aria-sort")).toBe("descending") + click($("#date")) + expect(value("order")).toBe("ASC") + }) + + it("leaves controls inside headings and other clicks alone", () => { + html(page()) + init() + click($("#all")) + click($("#plain")) + document.dispatchEvent(new MouseEvent("click", { bubbles: true })) + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })) + expect(submit).not.toHaveBeenCalled() + }) + + it("does nothing without the form", () => { + html(page().replace('data-form-sortable="#filter"', 'data-form-sortable="#nope"')) + init() + click($("#name")) + expect(submit).not.toHaveBeenCalled() + + html(`
    `) + sort($("#x")) + expect(submit).not.toHaveBeenCalled() + }) + + it("falls back to submit() without requestSubmit", () => { + delete (HTMLFormElement.prototype as Partial).requestSubmit + html(page()) + const native = vi.spyOn(HTMLFormElement.prototype, "submit").mockImplementation(() => undefined) + sort($("#name")) + expect(native).toHaveBeenCalledOnce() + }) +}) diff --git a/tests/js/submit.test.ts b/tests/js/submit.test.ts new file mode 100644 index 0000000..e3b9080 --- /dev/null +++ b/tests/js/submit.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest" + +import { init, setBusy } from "../../resources/js/submit" +import { $, flush, html } from "./helpers" + +const button = (id: string, extra = "") => + `` + +function submit(form: HTMLFormElement, submitter?: HTMLElement): SubmitEvent { + const event = new SubmitEvent("submit", { bubbles: true, cancelable: true, submitter }) + form.dispatchEvent(event) + return event +} + +describe("submit spinner", () => { + it("shows the clicked button's spinner and disables it after the submit event", async () => { + html(`
    ${button("save")}${button("other")}
    `) + init() + + const event = submit($("#f"), $("#save")) + expect(event.defaultPrevented).toBe(false) + expect($("#save").disabled).toBe(false) + + await flush() + expect($("#save").disabled).toBe(true) + expect($("#save").getAttribute("aria-busy")).toBe("true") + expect($("#save svg").hasAttribute("hidden")).toBe(false) + expect($("#other").disabled).toBe(false) + }) + + it("animates every animated button when there's no submitter", async () => { + html(`
    ${button("a")}
    ${button("b", 'form="f"')}`) + init() + + submit($("#f")) + await flush() + expect($("#a").disabled).toBe(true) + expect($("#b").disabled).toBe(true) + }) + + it("does nothing for plain submitters or cancelled submissions", async () => { + html(`
    ${button("a")}
    `) + init() + + submit($("#f"), $("#plain")) + await flush() + expect($("#a").disabled).toBe(false) + + $("#f").addEventListener("submit", (event) => event.preventDefault(), { once: true }) + submit($("#f"), $("#a")) + await flush() + expect($("#a").disabled).toBe(false) + + document.dispatchEvent(new SubmitEvent("submit")) + }) + + it("restores buttons when the page comes back from the cache", async () => { + html(`
    ${button("a")}
    `) + init() + + submit($("#f"), $("#a")) + await flush() + + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })) + expect($("#a").disabled).toBe(true) + + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true })) + expect($("#a").disabled).toBe(false) + expect($("#a").hasAttribute("aria-busy")).toBe(false) + expect($("#a svg").hasAttribute("hidden")).toBe(true) + }) + + it("sets links busy without disabling them", () => { + html(``) + setBusy($("#l"), true) + expect($("#l").getAttribute("aria-busy")).toBe("true") + }) +}) + +describe("focus on error", () => { + it("focuses the error summary", () => { + html(`
    ގޯސް
    +
    `) + init() + expect(document.activeElement).toBe($("#summary")) + }) + + it("focuses the first invalid control without a summary", () => { + html(`
    `) + init() + expect(document.activeElement).toBe($("#name")) + }) + + it("doesn't steal focus or focus forms without errors", () => { + html(`
    +
    `) + $("#auto").focus() + init() + expect(document.activeElement).toBe($("#auto")) + + $("#auto").blur() + const focus = vi.spyOn(HTMLElement.prototype, "focus") + init() + expect(focus).not.toHaveBeenCalled() + }) +}) diff --git a/tests/js/thaana.test.ts b/tests/js/thaana.test.ts new file mode 100644 index 0000000..c5e3a75 --- /dev/null +++ b/tests/js/thaana.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" + +import { controllerFor, init } from "../../resources/js/thaana" +import { $, html } from "./helpers" + +function type(el: HTMLInputElement | HTMLTextAreaElement, text: string): void { + for (const data of text) { + const event = new InputEvent("beforeinput", { inputType: "insertText", data, bubbles: true, cancelable: true }) + if (el.dispatchEvent(event)) { + el.setRangeText(data, el.selectionStart ?? el.value.length, el.selectionEnd ?? el.value.length, "end") + } + } +} + +describe("thaana", () => { + it("types Thaana from a Latin keyboard", () => { + html( + ``, + ) + init() + + type($("#name"), "mAle") + expect($("#name").value).toBe("މާލެ") + + type($("#notes"), "ali") + expect($("#notes").value).toBe("ަލި") + + type($("#plain"), "ali") + expect($("#plain").value).toBe("ali") + }) + + it("uses the field's keymap and attaches once", () => { + html( + ``, + ) + init() + const controller = controllerFor($("#a")) + init() + + expect(controllerFor($("#a"))).toBe(controller) + type($("#a"), "q") + expect($("#a").value).toBe("ޫ") + type($("#b"), "q") + expect($("#b").value).toBe("ް") + }) + + it("can be turned off", () => { + html(``) + init() + controllerFor($("#a"))?.setEnabled(false) + type($("#a"), "a") + expect($("#a").value).toBe("a") + }) +}) diff --git a/tests/js/widgets.test.ts b/tests/js/widgets.test.ts new file mode 100644 index 0000000..d519aa0 --- /dev/null +++ b/tests/js/widgets.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { activateTab, closeDialog, init, openDialog } from "../../resources/js/widgets" +import { $, click, html, key } from "./helpers" + +describe("alerts", () => { + it("removes the dismissed callout", () => { + html(`
    ސޭވްކުރެވިއްޖެ
    `) + init() + click($("#x")) + expect(document.querySelector("#c")).toBeNull() + }) + + it("ignores Bootstrap alerts", () => { + html(`
    +
    `) + init() + click($("#x")) + click($("#y")) + expect(document.querySelector("#c")).not.toBeNull() + expect(document.querySelector("#d")).not.toBeNull() + }) +}) + +describe("dialogs", () => { + const markup = ` + + ހުޅުވާ + +
    +

    ތެރެ

    ` + + describe("without showModal", () => { + it("opens and closes with the open attribute", () => { + html(markup) + init() + expect($("#dialog").getAttribute("data-state")).toBe("closed") + + const event = click($("#open2")) + expect(event.defaultPrevented).toBe(true) + expect($("#dialog").hasAttribute("open")).toBe(true) + expect($("#dialog").getAttribute("data-state")).toBe("open") + + click($("#close")) + expect($("#dialog").hasAttribute("open")).toBe(false) + expect($("#dialog").getAttribute("data-state")).toBe("closed") + + click($("#bad")) + click($("#notdialog")) + }) + }) + + describe("with showModal", () => { + beforeEach(() => { + HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) { + this.setAttribute("open", "") + } + HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) { + this.removeAttribute("open") + this.dispatchEvent(new Event("close")) + } + }) + + afterEach(() => { + delete (HTMLDialogElement.prototype as Partial).showModal + delete (HTMLDialogElement.prototype as Partial).close + }) + + it("opens with showModal and keeps data-state", () => { + html(markup) + init() + click($("#open")) + expect($("#dialog").open).toBe(true) + expect($("#dialog").getAttribute("data-state")).toBe("open") + + openDialog($("#dialog")) + expect($("#dialog").getAttribute("data-state")).toBe("open") + + // Escape closes natively; the close event updates the state. + $("#dialog").close() + expect($("#dialog").getAttribute("data-state")).toBe("closed") + }) + + it("closes on a backdrop click only", () => { + html(markup) + init() + openDialog($("#dialog")) + vi.spyOn($("#dialog"), "getBoundingClientRect").mockReturnValue(new DOMRect(100, 100, 200, 200)) + + click($("#body"), { clientX: 10, clientY: 10, detail: 1 }) + expect($("#dialog").open).toBe(true) + + click($("#dialog"), { clientX: 150, clientY: 150, detail: 1 }) + expect($("#dialog").open).toBe(true) + + click($("#dialog"), { clientX: 0, clientY: 0, detail: 0 }) + expect($("#dialog").open).toBe(true) + + click($("#dialog"), { clientX: 10, clientY: 150, detail: 1 }) + expect($("#dialog").open).toBe(false) + }) + + it("closes programmatically", () => { + html(markup) + init() + openDialog($("#dialog")) + closeDialog($("#dialog")) + expect($("#dialog").getAttribute("data-state")).toBe("closed") + }) + }) +}) + +describe("tabs", () => { + const tabs = (dir = "ltr") => ` +
    +
    + + + + +
    +
    1
    + + +
    ` + + it("switches tabs on click", () => { + html(tabs()) + init() + click($("#t2")) + + expect($("#t2").getAttribute("aria-selected")).toBe("true") + expect($("#t2").getAttribute("data-state")).toBe("active") + expect($("#t2").tabIndex).toBe(0) + expect($("#t1").getAttribute("aria-selected")).toBe("false") + expect($("#t1").tabIndex).toBe(-1) + expect($("#p2").classList.contains("hidden")).toBe(false) + expect($("#p2").getAttribute("data-state")).toBe("active") + expect($("#p1").classList.contains("hidden")).toBe(true) + expect($("#p1").classList.contains("active")).toBe(false) + + click($("#t3")) + expect($("#t2").getAttribute("aria-selected")).toBe("true") + }) + + it("moves with the arrow keys, skipping disabled tabs", () => { + html(tabs()) + init() + $("#t1").focus() + + key($("#t1"), "ArrowRight") + expect(document.activeElement).toBe($("#t2")) + key($("#t2"), "ArrowRight") + expect(document.activeElement).toBe($("#t4")) + key($("#t4"), "ArrowRight") + expect(document.activeElement).toBe($("#t1")) + key($("#t1"), "ArrowLeft") + expect(document.activeElement).toBe($("#t4")) + key($("#t4"), "Home") + expect(document.activeElement).toBe($("#t1")) + key($("#t1"), "End") + expect(document.activeElement).toBe($("#t4")) + expect(key($("#t4"), "a").defaultPrevented).toBe(false) + }) + + it("reverses the arrow keys right to left", () => { + html(tabs("rtl")) + init() + key($("#t1"), "ArrowLeft") + expect($("#t2").getAttribute("aria-selected")).toBe("true") + key($("#t2"), "ArrowRight") + expect($("#t1").getAttribute("aria-selected")).toBe("true") + }) + + it("uses up and down for vertical tabs", () => { + html(tabs().replace('role="tablist"', 'role="tablist" aria-orientation="vertical"')) + init() + key($("#t1"), "ArrowDown") + expect($("#t2").getAttribute("aria-selected")).toBe("true") + key($("#t2"), "ArrowUp") + expect($("#t1").getAttribute("aria-selected")).toBe("true") + }) + + it("handles missing panels", () => { + html(`
    +
    `) + activateTab($("#a")) + expect($("#a").getAttribute("aria-selected")).toBe("true") + }) +}) + +describe("menus", () => { + const menu = ` +
    ބޭނުންތައް +
    x
    +
    +

    ބޭރު

    ` + + it("closes on an outside click", () => { + html(menu) + init() + click($("#inside")) + expect($("#m").open).toBe(true) + click($("#outside")) + expect($("#m").open).toBe(false) + }) + + it("closes when an item is chosen", () => { + html(menu) + init() + click($("#item")) + expect($("#m").open).toBe(false) + }) + + it("closes on Escape and returns focus to the summary", () => { + html(menu) + init() + key($("#item"), "Escape") + expect($("#m").open).toBe(false) + expect(document.activeElement).toBe($("#s")) + key($("#outside"), "Escape") + }) + + it("closes on clicks on the document itself", () => { + html(menu) + init() + document.dispatchEvent(new MouseEvent("click", { bubbles: true })) + expect($("#m").open).toBe(false) + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) + }) +}) diff --git a/tests/js/wysiwyg.test.ts b/tests/js/wysiwyg.test.ts new file mode 100644 index 0000000..294495f --- /dev/null +++ b/tests/js/wysiwyg.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest" + +import { init, settingsFor } from "../../resources/js/wysiwyg" +import { $, html } from "./helpers" + +describe("wysiwyg", () => { + it("does nothing without TinyMCE", () => { + html(``) + init() + expect($("#t").hasAttribute("data-forms-ready")).toBe(false) + }) + + it("initialises TinyMCE once per textarea with the package's settings", () => { + const tinymce = { init: vi.fn() } + vi.stubGlobal("tinymce", tinymce) + html(``) + + init() + init() + + expect(tinymce.init).toHaveBeenCalledOnce() + expect(tinymce.init).toHaveBeenCalledWith( + expect.objectContaining({ + target: $("#t"), + menubar: false, + plugins: ["autolink", "link", "lists", "directionality", "code", "visualchars", "quickbars"], + quickbars_insert_toolbar: false, + directionality: "ltr", + }), + ) + }) + + it("follows the textarea's or the page's direction", () => { + html(``) + expect(settingsFor($("#a")).directionality).toBe("rtl") + expect(settingsFor($("#b")).directionality).toBe("ltr") + + document.documentElement.setAttribute("dir", "rtl") + expect(settingsFor($("#b")).directionality).toBe("rtl") + expect(String((settingsFor($("#b")).toolbar as string[])[0])).toContain("ltr rtl") + }) + + it("picks up TinyMCE loaded later, on window load", () => { + const tinymce = { init: vi.fn() } + html(``) + vi.stubGlobal("tinymce", tinymce) + window.dispatchEvent(new Event("load")) + expect(tinymce.init).not.toHaveBeenCalled() + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8d7f72c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["resources/js", "tests/js", "*.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..77b9a0f --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,43 @@ +import tailwindcss from "@tailwindcss/vite" +import { defineConfig } from "vite" + +/** + * Builds the prebuilt assets in dist/ for apps that don't build javaabu/forms with + * their own Vite. javaabu/manifest finds them through dist/.vite/manifest.json, keyed + * by these entry paths. Third-party libraries are bundled in; dist/ is committed. + */ +export const entries = [ + "resources/js/forms.ts", + "resources/js/select.ts", + "resources/js/dates.ts", + "resources/js/files.ts", + "resources/js/wysiwyg.ts", + "resources/css/select.css", + "resources/css/dates.css", + "resources/css/onu-standalone.css", +] + +export default defineConfig({ + plugins: [tailwindcss()], + // Relative URLs (fonts in the CSS), as javaabu/manifest serves dist/ from a sub-path. + base: "./", + publicDir: false, + build: { + outDir: "dist", + emptyOutDir: true, + manifest: true, + target: "es2020", + // Tailwind v4's baseline, so modern colors aren't given old-browser fallbacks. + cssTarget: ["chrome111", "edge111", "firefox128", "safari16.4"], + sourcemap: false, + reportCompressedSize: false, + rollupOptions: { + input: entries, + onLog(level, log, handler) { + // @javaabu/onu marks its modules "use client" for React; it means nothing here. + if (log.code === "MODULE_LEVEL_DIRECTIVE") return + handler(level, log) + }, + }, + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..95bdb75 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["tests/js/**/*.test.ts"], + setupFiles: ["tests/js/setup.ts"], + css: false, + coverage: { + provider: "v8", + include: ["resources/js/**/*.ts"], + reporter: ["text", "clover", "lcov"], + reportsDirectory: "coverage", + thresholds: { lines: 95, statements: 95, functions: 90, branches: 85 }, + }, + }, +})