Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,55 @@ docker compose up postgres

At development time we recommend you use the test applications set up as `main()` methods in `PetClinicIntegrationTests` (using the default H2 database and also adding Spring Boot Devtools), `MySqlTestApplication` and `PostgresIntegrationTests`. These are set up so that you can run the apps in your IDE to get fast feedback and also run the same classes as integration tests against the respective database. The MySql integration tests use Testcontainers to start the database in a Docker container, and the Postgres tests use Docker Compose to do the same thing.

## UI with Tailwind CSS and shadcn Components

The UI has been updated to use modern web technologies:

- **Tailwind CSS**: A utility-first CSS framework for rapid UI development
- **shadcn components**: A collection of accessible and customizable UI components

### Prerequisites for UI Development

- Node.js 18 or newer
- npm 9 or newer

### Building the UI

1. Install Node.js dependencies:

```bash
npm install
```

2. Build the Tailwind CSS:

```bash
npm run build:css
```

For development, you can use watch mode to automatically rebuild the CSS when files change:

```bash
npm run watch:css
```

### UI Components

The application uses the following shadcn components:

- **Button**: Used for all buttons and links with button styling
- **Input**: Used for all text and date input fields
- **Select**: Used for all dropdown select fields

These components are implemented as vanilla JavaScript classes that apply Tailwind CSS classes based on data attributes.

## Compiling the CSS

There is a `petclinic.css` in `src/main/resources/static/resources/css`. It was generated from the `petclinic.scss` source, combined with the [Bootstrap](https://getbootstrap.com/) library. If you make changes to the `scss`, or upgrade Bootstrap, you will need to re-compile the CSS resources using the Maven profile "css", i.e. `./mvnw package -P css`. There is no build profile for Gradle to compile the CSS.
There are two options for CSS in this project:

1. **Tailwind CSS (Recommended)**: The `petclinic.css` in `src/main/resources/static/resources/css` is generated from the `tailwind-input.css` source using Tailwind CSS. To compile it, run `npm run build:css`.

2. **Legacy SCSS**: The original CSS was generated from the `petclinic.scss` source, combined with the [Bootstrap](https://getbootstrap.com/) library. If you prefer to use this approach, you will need to re-compile the CSS resources using the Maven profile "css", i.e. `./mvnw package -P css`. There is no build profile for Gradle to compile the CSS.

## Working with Petclinic in your IDE

Expand Down
26 changes: 26 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "spring-petclinic",
"version": "1.0.0",
"description": "Spring PetClinic with Tailwind CSS and shadcn components",
"scripts": {
"build:css": "tailwindcss -i ./src/main/resources/static/resources/css/tailwind-input.css -o ./src/main/resources/static/resources/css/petclinic.css",
"watch:css": "tailwindcss -i ./src/main/resources/static/resources/css/tailwind-input.css -o ./src/main/resources/static/resources/css/petclinic.css --watch"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.363.0",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.1"
}
}
6 changes: 6 additions & 0 deletions postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
78 changes: 78 additions & 0 deletions src/main/resources/static/resources/js/components/ui/button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Button component based on shadcn/ui
* This is a client-side component that can be used with data-* attributes
*/
class Button {
constructor(element) {
this.element = element;
this.variant = element.dataset.variant || 'default';
this.size = element.dataset.size || 'default';
this.init();
}

init() {
// Apply base styles
this.element.classList.add(
'inline-flex',
'items-center',
'justify-center',
'whitespace-nowrap',
'rounded-md',
'text-sm',
'font-medium',
'ring-offset-background',
'transition-colors',
'focus-visible:outline-none',
'focus-visible:ring-2',
'focus-visible:ring-ring',
'focus-visible:ring-offset-2',
'disabled:pointer-events-none',
'disabled:opacity-50'
);

// Apply variant styles
switch (this.variant) {
case 'default':
this.element.classList.add('bg-primary', 'text-primary-foreground', 'hover:bg-primary/90');
break;
case 'destructive':
this.element.classList.add('bg-destructive', 'text-destructive-foreground', 'hover:bg-destructive/90');
break;
case 'outline':
this.element.classList.add('border', 'border-input', 'bg-background', 'hover:bg-accent', 'hover:text-accent-foreground');
break;
case 'secondary':
this.element.classList.add('bg-secondary', 'text-secondary-foreground', 'hover:bg-secondary/80');
break;
case 'ghost':
this.element.classList.add('hover:bg-accent', 'hover:text-accent-foreground');
break;
case 'link':
this.element.classList.add('text-primary', 'underline-offset-4', 'hover:underline');
break;
}

// Apply size styles
switch (this.size) {
case 'default':
this.element.classList.add('h-10', 'px-4', 'py-2');
break;
case 'sm':
this.element.classList.add('h-9', 'rounded-md', 'px-3');
break;
case 'lg':
this.element.classList.add('h-11', 'rounded-md', 'px-8');
break;
case 'icon':
this.element.classList.add('h-10', 'w-10');
break;
}
}
}

// Initialize all buttons on page load
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-component="button"]').forEach(element => {
new Button(element);
});
});
64 changes: 64 additions & 0 deletions src/main/resources/static/resources/js/components/ui/input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Input component based on shadcn/ui
* This is a client-side component that can be used with data-* attributes
*/
class Input {
constructor(element) {
this.element = element;
this.init();
}

init() {
// Apply base styles
this.element.classList.add(
'flex',
'h-10',
'w-full',
'rounded-md',
'border',
'border-input',
'bg-background',
'px-3',
'py-2',
'text-sm',
'ring-offset-background',
'file:border-0',
'file:bg-transparent',
'file:text-sm',
'file:font-medium',
'placeholder:text-muted-foreground',
'focus-visible:outline-none',
'focus-visible:ring-2',
'focus-visible:ring-ring',
'focus-visible:ring-offset-2',
'disabled:cursor-not-allowed',
'disabled:opacity-50'
);

// Add label styling if there's a label
const label = this.element.closest('.form-group')?.querySelector('label');
if (label) {
label.classList.add(
'text-sm',
'font-medium',
'leading-none',
'peer-disabled:cursor-not-allowed',
'peer-disabled:opacity-70'
);
}

// Add error styling if there's an error
const error = this.element.closest('.form-group')?.querySelector('.help-inline');
if (error) {
error.classList.add('text-destructive', 'text-sm', 'mt-1');
this.element.classList.add('border-destructive');
}
}
}

// Initialize all inputs on page load
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-component="input"]').forEach(element => {
new Input(element);
});
});
75 changes: 75 additions & 0 deletions src/main/resources/static/resources/js/components/ui/select.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Select component based on shadcn/ui
* This is a client-side component that can be used with data-* attributes
*/
class Select {
constructor(element) {
this.element = element;
this.init();
}

init() {
// Apply base styles to the select element
this.element.classList.add(
'flex',
'h-10',
'w-full',
'rounded-md',
'border',
'border-input',
'bg-background',
'px-3',
'py-2',
'text-sm',
'ring-offset-background',
'focus-visible:outline-none',
'focus-visible:ring-2',
'focus-visible:ring-ring',
'focus-visible:ring-offset-2',
'disabled:cursor-not-allowed',
'disabled:opacity-50'
);

// Add label styling if there's a label
const label = this.element.closest('.form-group')?.querySelector('label');
if (label) {
label.classList.add(
'text-sm',
'font-medium',
'leading-none',
'peer-disabled:cursor-not-allowed',
'peer-disabled:opacity-70'
);
}

// Add error styling if there's an error
const error = this.element.closest('.form-group')?.querySelector('.help-inline');
if (error) {
error.classList.add('text-destructive', 'text-sm', 'mt-1');
this.element.classList.add('border-destructive');
}

// Style option elements
Array.from(this.element.querySelectorAll('option')).forEach(option => {
option.classList.add(
'relative',
'cursor-default',
'select-none',
'py-1.5',
'pl-8',
'pr-2',
'text-sm',
'outline-none',
'data-[disabled]:pointer-events-none',
'data-[disabled]:opacity-50'
);
});
}
}

// Initialize all selects on page load
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-component="select"]').forEach(element => {
new Select(element);
});
});
37 changes: 37 additions & 0 deletions src/main/resources/static/resources/js/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Main JavaScript file for Spring PetClinic
* Initializes shadcn components and other functionality
*/

// Import component scripts
document.addEventListener('DOMContentLoaded', () => {
// Load component scripts
const componentScripts = [
'/resources/js/components/ui/button.js',
'/resources/js/components/ui/input.js',
'/resources/js/components/ui/select.js'
];

componentScripts.forEach(script => {
const scriptElement = document.createElement('script');
scriptElement.src = script;
document.body.appendChild(scriptElement);
});

// Initialize mobile menu toggle
const menuToggle = document.querySelector('.navbar-toggler');
const mainNavbar = document.querySelector('#main-navbar');

if (menuToggle && mainNavbar) {
menuToggle.addEventListener('click', () => {
const isExpanded = menuToggle.getAttribute('aria-expanded') === 'true';
menuToggle.setAttribute('aria-expanded', !isExpanded);

if (isExpanded) {
mainNavbar.classList.add('hidden', 'md:block');
} else {
mainNavbar.classList.remove('hidden');
}
});
}
});
16 changes: 8 additions & 8 deletions src/main/resources/templates/fragments/inputField.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@
<th:block th:fragment="input (label, name, type)">
<div th:with="valid=${!#fields.hasErrors(name)}"
th:class="${'form-group' + (valid ? '' : ' has-error')}"
class="form-group">
<label th:for="${name}" class="col-sm-2 control-label" th:text="${label}">Label</label>
<div class="col-sm-10">
class="form-group space-y-2 mb-4">
<label th:for="${name}" class="block text-sm font-medium" th:text="${label}">Label</label>
<div class="relative">
<div th:switch="${type}">
<input th:case="'text'" class="form-control" type="text" th:field="*{__${name}__}" />
<input th:case="'date'" class="form-control" type="date" th:field="*{__${name}__}"/>
<input th:case="'text'" data-component="input" type="text" th:field="*{__${name}__}" />
<input th:case="'date'" data-component="input" type="date" th:field="*{__${name}__}"/>
</div>
<span th:if="${valid}"
class="fa fa-ok form-control-feedback"
class="absolute right-3 top-3 text-green-500 fa fa-check"
aria-hidden="true"></span>
<th:block th:if="${!valid}">
<span
class="fa fa-remove form-control-feedback"
class="absolute right-3 top-3 text-destructive fa fa-times"
aria-hidden="true"></span>
<span class="help-inline" th:errors="*{__${name}__}">Error</span>
<span class="help-inline text-destructive text-sm mt-1 block" th:errors="*{__${name}__}">Error</span>
</th:block>
</div>
</div>
Expand Down
Loading
Loading