Merge pull request 'Bring the application and Gitea build pipeline to main' (#1) from feature/s4-gitea-ci into main
All checks were successful
Build, test and publish / verify (push) Successful in 2m9s
All checks were successful
Build, test and publish / verify (push) Successful in 2m9s
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
**/target
|
||||
.git
|
||||
.idea
|
||||
.mvn
|
||||
*.iml
|
||||
|
||||
**/node_modules
|
||||
**/.angular
|
||||
**/dist
|
||||
**/test-results
|
||||
**/playwright-report
|
||||
ci/artifacts
|
||||
# Allow only the verified runtime output for the CI prebuilt target.
|
||||
!agiledancers-be/target
|
||||
agiledancers-be/target/*
|
||||
!agiledancers-be/target/quarkus-app
|
||||
|
||||
**/unit-results
|
||||
44
.gitea/workflows/build.yaml
Normal file
44
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Build, test and publish
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: agiledancers-mini-pc
|
||||
env:
|
||||
CI: 'true'
|
||||
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
|
||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||
CI_REPOSITORY: ${{ gitea.repository }}
|
||||
CI_COMMIT: ${{ gitea.sha }}
|
||||
CI_REF: ${{ gitea.ref }}
|
||||
CI_RUN_ID: ${{ gitea.run_id }}
|
||||
steps:
|
||||
- name: Check out pushed commit
|
||||
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Identify workflow attempt
|
||||
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
|
||||
# container names and diagnostic artifacts, including reruns of one SHA.
|
||||
run: java ci/Ci.java attempt
|
||||
- name: Build and test both images
|
||||
run: java ci/Ci.java verify
|
||||
- name: Publish verified main images
|
||||
if: success() && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: java ci/Ci.java publish
|
||||
- name: Collect diagnostics and clean up test containers
|
||||
if: always()
|
||||
run: java ci/Ci.java cleanup
|
||||
- name: Retain diagnostics for fourteen days
|
||||
if: always()
|
||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: diagnostics-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
|
||||
path: ci/artifacts/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
*.iml
|
||||
.idea
|
||||
target
|
||||
# Local CI diagnostics and runner credentials/state
|
||||
/ci/artifacts/
|
||||
/ci/runner/.env
|
||||
/ci/runner/.runner
|
||||
/ci/runner/data/
|
||||
/ci/runner/local/
|
||||
|
||||
**/unit-results/
|
||||
**/__pycache__/
|
||||
2
.mvn/maven.config
Normal file
2
.mvn/maven.config
Normal file
@@ -0,0 +1,2 @@
|
||||
-s.mvn/settings-local.xml
|
||||
|
||||
265
.mvn/settings-local.xml
Normal file
265
.mvn/settings-local.xml
Normal file
@@ -0,0 +1,265 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
<!--
|
||||
| This is the configuration file for Maven. It can be specified at two levels:
|
||||
|
|
||||
| 1. User Level. This settings.xml file provides configuration for a single user,
|
||||
| and is normally provided in ${user.home}/.m2/settings.xml.
|
||||
|
|
||||
| NOTE: This location can be overridden with the CLI option:
|
||||
|
|
||||
| -s /path/to/user/settings.xml
|
||||
|
|
||||
| 2. Global Level. This settings.xml file provides configuration for all Maven
|
||||
| users on a machine (assuming they're all using the same Maven
|
||||
| installation). It's normally provided in
|
||||
| ${maven.conf}/settings.xml.
|
||||
|
|
||||
| NOTE: This location can be overridden with the CLI option:
|
||||
|
|
||||
| -gs /path/to/global/settings.xml
|
||||
|
|
||||
| The sections in this sample file are intended to give you a running start at
|
||||
| getting the most out of your Maven installation. Where appropriate, the default
|
||||
| values (values used when the setting is not specified) are provided.
|
||||
|
|
||||
|-->
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||
<!-- localRepository
|
||||
| The path to the local repository maven will use to store artifacts.
|
||||
|
|
||||
| Default: ${user.home}/.m2/repository
|
||||
<localRepository>/path/to/local/repo</localRepository>
|
||||
-->
|
||||
|
||||
<!-- interactiveMode
|
||||
| This will determine whether maven prompts you when it needs input. If set to false,
|
||||
| maven will use a sensible default value, perhaps based on some other setting, for
|
||||
| the parameter in question.
|
||||
|
|
||||
| Default: true
|
||||
<interactiveMode>true</interactiveMode>
|
||||
-->
|
||||
|
||||
<!-- offline
|
||||
| Determines whether maven should attempt to connect to the network when executing a build.
|
||||
| This will have an effect on artifact downloads, artifact deployment, and others.
|
||||
|
|
||||
| Default: false
|
||||
<offline>false</offline>
|
||||
-->
|
||||
|
||||
<!-- pluginGroups
|
||||
| This is a list of additional group identifiers that will be searched when resolving plugins by their prefix, i.e.
|
||||
| when invoking a command line like "mvn prefix:goal". Maven will automatically add the group identifiers
|
||||
| "org.apache.maven.plugins" and "org.codehaus.mojo" if these are not already contained in the list.
|
||||
|-->
|
||||
<pluginGroups>
|
||||
<!-- pluginGroup
|
||||
| Specifies a further group identifier to use for plugin lookup.
|
||||
<pluginGroup>com.your.plugins</pluginGroup>
|
||||
-->
|
||||
</pluginGroups>
|
||||
|
||||
<!-- TODO Since when can proxies be selected as depicted? -->
|
||||
<!-- proxies
|
||||
| This is a list of proxies which can be used on this machine to connect to the network.
|
||||
| Unless otherwise specified (by system property or command-line switch), the first proxy
|
||||
| specification in this list marked as active will be used.
|
||||
|-->
|
||||
<proxies>
|
||||
<!-- proxy
|
||||
| Specification for one proxy, to be used in connecting to the network.
|
||||
|
|
||||
<proxy>
|
||||
<id>optional</id>
|
||||
<active>true</active>
|
||||
<protocol>http</protocol>
|
||||
<username>proxyuser</username>
|
||||
<password>proxypass</password>
|
||||
<host>proxy.host.net</host>
|
||||
<port>80</port>
|
||||
<nonProxyHosts>local.net|some.host.com</nonProxyHosts>
|
||||
</proxy>
|
||||
-->
|
||||
</proxies>
|
||||
|
||||
<!-- servers
|
||||
| This is a list of authentication profiles, keyed by the server-id used within the system.
|
||||
| Authentication profiles can be used whenever maven must make a connection to a remote server.
|
||||
|-->
|
||||
<servers>
|
||||
<!-- server
|
||||
| Specifies the authentication information to use when connecting to a particular server, identified by
|
||||
| a unique name within the system (referred to by the 'id' attribute below).
|
||||
|
|
||||
| NOTE: You should either specify username/password OR privateKey/passphrase, since these pairings are
|
||||
| used together.
|
||||
|
|
||||
<server>
|
||||
<id>deploymentRepo</id>
|
||||
<username>repouser</username>
|
||||
<password>repopwd</password>
|
||||
</server>
|
||||
-->
|
||||
|
||||
<!-- Another sample, using keys to authenticate.
|
||||
<server>
|
||||
<id>siteServer</id>
|
||||
<privateKey>/path/to/private/key</privateKey>
|
||||
<passphrase>optional; leave empty if not used.</passphrase>
|
||||
</server>
|
||||
-->
|
||||
</servers>
|
||||
|
||||
<!-- mirrors
|
||||
| This is a list of mirrors to be used in downloading artifacts from remote repositories.
|
||||
|
|
||||
| It works like this: a POM may declare a repository to use in resolving certain artifacts.
|
||||
| However, this repository may have problems with heavy traffic at times, so people have mirrored
|
||||
| it to several places.
|
||||
|
|
||||
| That repository definition will have a unique id, so we can create a mirror reference for that
|
||||
| repository, to be used as an alternate download site. The mirror site will be the preferred
|
||||
| server for that repository.
|
||||
|-->
|
||||
<mirrors>
|
||||
<!-- mirror
|
||||
| Specifies a repository mirror site to use instead of a given repository. The repository that
|
||||
| this mirror serves has an ID that matches the mirrorOf element of this mirror. IDs are used
|
||||
| for inheritance and direct lookup purposes, and must be unique across the set of mirrors.
|
||||
|
|
||||
<mirror>
|
||||
<id>mirrorId</id>
|
||||
<mirrorOf>repositoryId</mirrorOf>
|
||||
<name>Human Readable Name for this Mirror.</name>
|
||||
<url>http://my.repository.com/repo/path</url>
|
||||
</mirror>
|
||||
-->
|
||||
<mirror>
|
||||
<id>maven-default-http-blocker</id>
|
||||
<mirrorOf>external:http:*</mirrorOf>
|
||||
<name>Pseudo repository to mirror external repositories initially using HTTP.</name>
|
||||
<url>http://0.0.0.0/</url>
|
||||
<blocked>true</blocked>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
|
||||
<!-- profiles
|
||||
| This is a list of profiles which can be activated in a variety of ways, and which can modify
|
||||
| the build process. Profiles provided in the settings.xml are intended to provide local machine-
|
||||
| specific paths and repository locations which allow the build to work in the local environment.
|
||||
|
|
||||
| For example, if you have an integration testing plugin - like cactus - that needs to know where
|
||||
| your Tomcat instance is installed, you can provide a variable here such that the variable is
|
||||
| dereferenced during the build process to configure the cactus plugin.
|
||||
|
|
||||
| As noted above, profiles can be activated in a variety of ways. One way - the activeProfiles
|
||||
| section of this document (settings.xml) - will be discussed later. Another way essentially
|
||||
| relies on the detection of a property, either matching a particular value for the property,
|
||||
| or merely testing its existence. Profiles can also be activated by JDK version prefix, where a
|
||||
| value of '1.4' might activate a profile when the build is executed on a JDK version of '1.4.2_07'.
|
||||
| Finally, the list of active profiles can be specified directly from the command line.
|
||||
|
|
||||
| NOTE: For profiles defined in the settings.xml, you are restricted to specifying only artifact
|
||||
| repositories, plugin repositories, and free-form properties to be used as configuration
|
||||
| variables for plugins in the POM.
|
||||
|
|
||||
|-->
|
||||
<profiles>
|
||||
<!-- profile
|
||||
| Specifies a set of introductions to the build process, to be activated using one or more of the
|
||||
| mechanisms described above. For inheritance purposes, and to activate profiles via <activatedProfiles/>
|
||||
| or the command line, profiles have to have an ID that is unique.
|
||||
|
|
||||
| An encouraged best practice for profile identification is to use a consistent naming convention
|
||||
| for profiles, such as 'env-dev', 'env-test', 'env-production', 'user-jdcasey', 'user-brett', etc.
|
||||
| This will make it more intuitive to understand what the set of introduced profiles is attempting
|
||||
| to accomplish, particularly when you only have a list of profile id's for debug.
|
||||
|
|
||||
| This profile example uses the JDK version to trigger activation, and provides a JDK-specific repo.
|
||||
<profile>
|
||||
<id>jdk-1.4</id>
|
||||
|
||||
<activation>
|
||||
<jdk>1.4</jdk>
|
||||
</activation>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>jdk14</id>
|
||||
<name>Repository for JDK 1.4 builds</name>
|
||||
<url>http://www.myhost.com/maven/jdk14</url>
|
||||
<layout>default</layout>
|
||||
<snapshotPolicy>always</snapshotPolicy>
|
||||
</repository>
|
||||
</repositories>
|
||||
</profile>
|
||||
-->
|
||||
|
||||
<!--
|
||||
| Here is another profile, activated by the property 'target-env' with a value of 'dev', which
|
||||
| provides a specific path to the Tomcat instance. To use this, your plugin configuration might
|
||||
| hypothetically look like:
|
||||
|
|
||||
| ...
|
||||
| <plugin>
|
||||
| <groupId>org.myco.myplugins</groupId>
|
||||
| <artifactId>myplugin</artifactId>
|
||||
|
|
||||
| <configuration>
|
||||
| <tomcatLocation>${tomcatPath}</tomcatLocation>
|
||||
| </configuration>
|
||||
| </plugin>
|
||||
| ...
|
||||
|
|
||||
| NOTE: If you just wanted to inject this configuration whenever someone set 'target-env' to
|
||||
| anything, you could just leave off the <value/> inside the activation-property.
|
||||
|
|
||||
<profile>
|
||||
<id>env-dev</id>
|
||||
|
||||
<activation>
|
||||
<property>
|
||||
<name>target-env</name>
|
||||
<value>dev</value>
|
||||
</property>
|
||||
</activation>
|
||||
|
||||
<properties>
|
||||
<tomcatPath>/path/to/tomcat/instance</tomcatPath>
|
||||
</properties>
|
||||
</profile>
|
||||
-->
|
||||
</profiles>
|
||||
|
||||
<!-- activeProfiles
|
||||
| List of profiles that are active for all builds.
|
||||
|
|
||||
<activeProfiles>
|
||||
<activeProfile>alwaysActiveProfile</activeProfile>
|
||||
<activeProfile>anotherAlwaysActiveProfile</activeProfile>
|
||||
</activeProfiles>
|
||||
-->
|
||||
</settings>
|
||||
12
AGENTS.md
Normal file
12
AGENTS.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Project skills and specifications
|
||||
|
||||
- Project skills are stored in `skills/`; specifications belong in `specs/`.
|
||||
- Use `grill-me` only when the user explicitly requests it, for example
|
||||
`Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`.
|
||||
- Before using it, read `skills/grill-me/SKILL.md` and its dependency
|
||||
`skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these
|
||||
files directly and follow their instructions within the available tools.
|
||||
- Installing or mentioning a skill does not start an interview. Do not apply
|
||||
`grilling` automatically to ordinary development requests.
|
||||
- Keep the upstream skill files unchanged. Source revision and license are
|
||||
recorded in `skills/upstream.json` and `skills/LICENSE.mattpocock`.
|
||||
33
CHANGELOG.md
Normal file
33
CHANGELOG.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Angular 22 frontend in `agiledancers-fe` with Dutch-language school management screens.
|
||||
- Forms for dancers, groups and shows, including multiple group memberships and deletion confirmation.
|
||||
- Development proxy to the Quarkus REST API, frontend unit tests and end-to-end browser tests.
|
||||
|
||||
## [1.0] - 2026-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- Quarkus backend running on Java 25, with health endpoints and Docker support.
|
||||
- Domain model for dance schools, dancers, dance groups, coaches, shows and day parts.
|
||||
- REST API to create, list, retrieve, update and delete dance schools, dancers, dance groups and shows.
|
||||
- Membership management allowing a dancer to belong to multiple groups within a school.
|
||||
- Shows with a name, date and three day parts: morning, afternoon and evening.
|
||||
- Input validation, school-scoped resource access and cleanup of memberships when dancers are deleted.
|
||||
- Shared in-memory storage for the REST API.
|
||||
- Separate API model records in `rest.apimodel`.
|
||||
- ArchUnit checks for package dependencies, domain independence, package cycles and API model conventions.
|
||||
- HTTP integration tests for resource management, memberships, validation and school isolation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Adding entities to initially empty dance school and dance group collections now retains them.
|
||||
|
||||
### Limitations
|
||||
|
||||
- Data is lost when the application stops.
|
||||
- Assigning dance groups to show day parts is not yet available.
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM maven:3.9-eclipse-temurin-25 AS build
|
||||
WORKDIR /workspace
|
||||
COPY pom.xml ./
|
||||
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
|
||||
COPY agiledancers-be/src agiledancers-be/src
|
||||
RUN mvn -B -ntp clean verify
|
||||
|
||||
FROM eclipse-temurin:25-jre AS runtime
|
||||
WORKDIR /app
|
||||
USER 10001:10001
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "/app/quarkus-run.jar"]
|
||||
|
||||
# CI packages the exact output that already passed mvn clean verify.
|
||||
FROM runtime AS prebuilt
|
||||
COPY --chown=10001:10001 agiledancers-be/target/quarkus-app/ ./
|
||||
|
||||
# Default standalone build still compiles and tests from source.
|
||||
FROM runtime AS standalone
|
||||
COPY --from=build --chown=10001:10001 /workspace/agiledancers-be/target/quarkus-app/ ./
|
||||
177
README.md
177
README.md
@@ -1,3 +1,178 @@
|
||||
# agiledancers
|
||||
|
||||
To map dancegroups to performancedays
|
||||
To map dance groups to performance days. The backend runs on Quarkus with Java 25.
|
||||
|
||||
Requires JDK 25 and Maven 3.9.0 or newer. Set `JAVA_HOME` to your JDK 25 installation.
|
||||
|
||||
Build and run all tests (including `*Sit` tests) from the repository root:
|
||||
|
||||
```sh
|
||||
mvn clean verify
|
||||
```
|
||||
|
||||
Start development mode with live reload from the repository root:
|
||||
|
||||
```sh
|
||||
mvn -pl agiledancers-be quarkus:dev
|
||||
```
|
||||
|
||||
Run the packaged application after building:
|
||||
|
||||
```sh
|
||||
java -jar agiledancers-be/target/quarkus-app/quarkus-run.jar
|
||||
```
|
||||
|
||||
Build and run a Docker container from the repository root (only Docker is required):
|
||||
|
||||
```sh
|
||||
docker build -t agiledancers .
|
||||
docker run --rm --name agiledancers -p 8080:8080 agiledancers
|
||||
```
|
||||
|
||||
The Docker build compiles the application and runs the tests in a Maven build stage,
|
||||
then copies the Quarkus application into a Java 25 runtime image running as a non-root user.
|
||||
|
||||
Check readiness at <http://localhost:8080/q/health/ready> or overall health at
|
||||
<http://localhost:8080/q/health>. For example:
|
||||
|
||||
```sh
|
||||
curl --fail http://localhost:8080/q/health/ready
|
||||
```
|
||||
|
||||
Quarkus configuration can be overridden with environment variables, for example
|
||||
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
|
||||
|
||||
The REST API uses shared in-memory storage; data is lost when the process stops.
|
||||
|
||||
Architecture checks run with the normal test suite, or separately with:
|
||||
|
||||
```sh
|
||||
mvn -pl agiledancers-be -Dtest=ArchitectureTest test
|
||||
```
|
||||
|
||||
`ArchitectureTest` guards the following packages below `nl.agilecc.agiledancers`:
|
||||
|
||||
| Package | Responsibility | Allowed dependencies within the application |
|
||||
| --- | --- | --- |
|
||||
| `model` | Domain entities and specifications | Model |
|
||||
| `service` | Application use cases and future ports | Model, repositories |
|
||||
| `repository` | Current in-memory persistence | Model |
|
||||
| `rest` | Future REST services | Services, model |
|
||||
| `solver` | Future OR-Tools calculations | Services (ports), model |
|
||||
|
||||
REST and solver packages are optional until implemented. OR-Tools dependencies
|
||||
are allowed only in `solver`; JAX-RS and RESTEasy dependencies only in `rest`.
|
||||
The model may depend only on itself, Java and Lombok. Package cycles and
|
||||
production classes outside these packages fail the architecture tests.
|
||||
|
||||
API models live in `rest.apimodel`, each in its own Java file as a public top-level
|
||||
class or record. Their names end in `Request`, `Response`, `View`, `Dto` or `DTO`.
|
||||
Architecture tests enforce this naming and location convention and require all
|
||||
records in the REST layer to reside in `rest.apimodel`.
|
||||
|
||||
The current services instantiate concrete repositories, so this is not yet strict
|
||||
Onion architecture. That requires repository interfaces in the core and injected
|
||||
implementations in an outer adapter. For future calculations, define a port in
|
||||
`service.port` using domain types and implement it in `solver`; services must not
|
||||
depend on solver implementations or expose OR-Tools types.
|
||||
|
||||
|
||||
## School management REST API
|
||||
|
||||
All endpoints consume and produce JSON. Resources are scoped to their dance school.
|
||||
A dancer can belong to multiple dance groups within the same school. Memberships
|
||||
are managed separately from group details and survive updates to dancers and groups.
|
||||
|
||||
| Resource collection | Item URL |
|
||||
| --- | --- |
|
||||
| `/api/dance-schools` | `/api/dance-schools/{schoolId}` |
|
||||
| `/api/dance-schools/{schoolId}/dancers` | `.../dancers/{dancerId}` |
|
||||
| `/api/dance-schools/{schoolId}/dance-groups` | `.../dance-groups/{groupId}` |
|
||||
| `/api/dance-schools/{schoolId}/shows` | `.../shows/{showId}` |
|
||||
|
||||
Each collection supports `GET` (list) and `POST` (create). Each item supports `GET`,
|
||||
`PUT` (replace editable details), and `DELETE`. Creation returns `201` with a
|
||||
`Location` header; updates return `200`; deletion returns `204`. Invalid input
|
||||
returns `400`, and missing resources or resources belonging to another school
|
||||
return `404`. Client-supplied IDs are not used.
|
||||
|
||||
Request bodies (dates use ISO `yyyy-MM-dd`):
|
||||
|
||||
```json
|
||||
{"name": "24 Dance Studio"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"name": "Sam", "birthDate": "2010-05-12"}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "Teens",
|
||||
"type": "REGULAR",
|
||||
"startAge": 12,
|
||||
"endAge": 18,
|
||||
"styles": ["HIPHOP", "JAZZ"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{"name": "Annual show", "date": "2027-06-12"}
|
||||
```
|
||||
|
||||
Names and categories must be nonblank. Birth dates cannot be in the future.
|
||||
Both ages must be nonnegative and `startAge <= endAge`; `styles` is required
|
||||
and may be empty. Group types are `SHOW_CASE`, `PRE_EDUCATION`, `SELECTION`,
|
||||
`REGULAR`, `PRE_SCHOOLER`. Styles are `HIPHOP`, `JAZZ`, `MODERN`, `STREETDANCE`,
|
||||
`CLASSIC`, `BREAKDANCE`.
|
||||
|
||||
School responses include `dancerIds`, `danceGroupIds`, and `showIds`; group
|
||||
responses include `dancerIds`. To add or remove a dancer's group membership:
|
||||
|
||||
```sh
|
||||
curl -X PUT http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
|
||||
curl -X DELETE http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
|
||||
```
|
||||
|
||||
Both operations return `204` and are idempotent for existing groups and dancers.
|
||||
Deleting a dancer removes all their memberships; deleting a group retains the
|
||||
dancers; deleting a school also deletes its dancers, groups and shows.
|
||||
|
||||
Shows have a name, a date and three automatically created day parts: `MORNING`,
|
||||
`AFTERNOON`, `EVENING`. Each day part has an empty `danceGroupIds` list; assigning
|
||||
groups is not exposed yet. Multiple shows per year are allowed.
|
||||
|
||||
Run the HTTP integration tests with:
|
||||
|
||||
```sh
|
||||
mvn -pl agiledancers-be -Dtest=SchoolManagementResourceTest test
|
||||
```
|
||||
|
||||
|
||||
## Angular frontend
|
||||
|
||||
The Angular 22 frontend is in [`agiledancers-fe`](agiledancers-fe/README.md),
|
||||
next to `agiledancers-be`. It provides Dutch-language screens to manage schools,
|
||||
dancers, groups, memberships and shows through the REST API.
|
||||
|
||||
Use Node.js 22.22.3+ (22.x), 24.15.0+ (24.x), or 26+.
|
||||
Start the backend as described above, then run in a second terminal:
|
||||
|
||||
```sh
|
||||
cd agiledancers-fe
|
||||
npm ci
|
||||
npm start
|
||||
```
|
||||
|
||||
Open <http://localhost:4200>. The development proxy forwards API requests to the
|
||||
backend on port 8080. Frontend builds and tests run independently of Maven:
|
||||
`npm run build` and `npm test`. See the frontend README for browser tests and
|
||||
production hosting requirements.
|
||||
|
||||
## Gitea CI
|
||||
|
||||
Every branch push builds and tests both applications and their Docker images.
|
||||
Only successful `main` runs publish images to the Synology registry. See
|
||||
[the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets,
|
||||
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
|
||||
for the current operational status.
|
||||
|
||||
83
agiledancers-be/pom.xml
Normal file
83
agiledancers-be/pom.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>nl.agilecc.agiledancers</groupId>
|
||||
<artifactId>agiledancers</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>agiledancers-be</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-jackson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-hibernate-validator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-smallrye-health</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.ortools</groupId>
|
||||
<artifactId>ortools-java</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- test dependencies -->
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-junit</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.rest-assured</groupId>
|
||||
<artifactId>rest-assured</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.datafaker</groupId>
|
||||
<artifactId>datafaker</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-maven-plugin</artifactId>
|
||||
<version>${quarkus.platform.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>build</goal>
|
||||
<goal>generate-code</goal>
|
||||
<goal>generate-code-tests</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,30 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@EqualsAndHashCode
|
||||
@ToString
|
||||
@NoArgsConstructor
|
||||
public abstract class AbstractEntity {
|
||||
private Long id;
|
||||
|
||||
protected <T> void addNullSafe(Set<T> set, T t) {
|
||||
emptyIfNull(set).add(t);
|
||||
}
|
||||
|
||||
private <T> Set<T> emptyIfNull(Set<T> set) {
|
||||
return set == null ?
|
||||
new HashSet<>() :
|
||||
set;
|
||||
}
|
||||
|
||||
protected <T> Set<T> unmodifiableNullSafe(Set<T> set) {
|
||||
return set == null ? Collections.emptySet() : Collections.unmodifiableSet(set);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@Builder
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class Coach extends AbstractEntity {
|
||||
@ToString.Include
|
||||
private final String name;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@Builder
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class DanceGroup extends AbstractEntity {
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private String category;
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private DanceGroupType type;
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private int startAge;
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private int endAge;
|
||||
@ToString.Include
|
||||
@Builder.Default
|
||||
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
||||
@ToString.Include
|
||||
@Builder.Default
|
||||
private final Set<DanceGroupStyle> styles = new LinkedHashSet<>();
|
||||
@ToString.Include
|
||||
private Coach coach;
|
||||
|
||||
public Set<Dancer> getDancers() {
|
||||
return super.unmodifiableNullSafe(dancers);
|
||||
}
|
||||
|
||||
public void addDancer(Dancer dancer) {
|
||||
super.addNullSafe(dancers, dancer);
|
||||
}
|
||||
|
||||
public Set<DanceGroupStyle> getStyles() {
|
||||
return super.unmodifiableNullSafe(styles);
|
||||
}
|
||||
|
||||
public void addStyle(DanceGroupStyle style) {
|
||||
super.addNullSafe(styles, style);
|
||||
}
|
||||
|
||||
public void removeDancer(Dancer dancer) {
|
||||
dancers.remove(dancer);
|
||||
}
|
||||
|
||||
public void replaceStyles(Set<DanceGroupStyle> styles) {
|
||||
this.styles.clear();
|
||||
this.styles.addAll(styles);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@Builder
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class DanceSchool extends AbstractEntity {
|
||||
@Builder.Default
|
||||
private final java.util.UUID key = java.util.UUID.randomUUID();
|
||||
@Setter
|
||||
private String name;
|
||||
@Builder.Default
|
||||
private final Set<Dancer> dancers = new LinkedHashSet<>();
|
||||
@Builder.Default
|
||||
private final Set<DanceGroup> danceGroups = new LinkedHashSet<>();
|
||||
@Builder.Default
|
||||
private final Set<Show> shows = new LinkedHashSet<>();
|
||||
@Builder.Default
|
||||
private final Set<Coach> coaches = new LinkedHashSet<>();
|
||||
|
||||
public Set<Dancer> getDancers() {
|
||||
return super.unmodifiableNullSafe(dancers);
|
||||
}
|
||||
|
||||
public Set<DanceGroup> getDanceGroups() {
|
||||
return super.unmodifiableNullSafe(danceGroups);
|
||||
}
|
||||
|
||||
public Set<Show> getShows() {
|
||||
return super.unmodifiableNullSafe(shows);
|
||||
}
|
||||
|
||||
public Set<Coach> getCoaches() {
|
||||
return super.unmodifiableNullSafe(coaches);
|
||||
}
|
||||
|
||||
public void addDancer(Dancer dancer) {
|
||||
super.addNullSafe(dancers, dancer);
|
||||
}
|
||||
|
||||
public void addDanceGroup(DanceGroup danceGroup) {
|
||||
super.addNullSafe(danceGroups, danceGroup);
|
||||
}
|
||||
|
||||
public void addShow(Show show) {
|
||||
super.addNullSafe(shows, show);
|
||||
}
|
||||
|
||||
public void addCoach(Coach coach) {
|
||||
super.addNullSafe(coaches, coach);
|
||||
}
|
||||
|
||||
public void removeDancer(Dancer entity) {
|
||||
dancers.remove(entity);
|
||||
}
|
||||
|
||||
public void removeDanceGroup(DanceGroup entity) {
|
||||
danceGroups.remove(entity);
|
||||
}
|
||||
|
||||
public void removeShow(Show entity) {
|
||||
shows.remove(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@Builder
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class Dancer extends AbstractEntity {
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private String name;
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private LocalDate birthDate;
|
||||
|
||||
public int age() {
|
||||
return Period.between(birthDate, LocalDate.now()).getYears();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@Builder
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class DayPart extends AbstractEntity {
|
||||
@ToString.Include
|
||||
private final DayPartType type;
|
||||
@ToString.Include
|
||||
private List<DanceGroup> danceGroups;
|
||||
|
||||
public void removeDanceGroup(DanceGroup group) {
|
||||
danceGroups = danceGroups.stream().filter(value -> value != group).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package nl.agilecc.agiledancers.model;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@Builder
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class Show extends AbstractEntity {
|
||||
@Setter
|
||||
private String name;
|
||||
@Setter
|
||||
private LocalDate date;
|
||||
@ToString.Include
|
||||
private final List<DayPart> dayParts;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.model.specifications;
|
||||
|
||||
public enum DanceGroupStyle {
|
||||
HIPHOP, JAZZ, MODERN, STREETDANCE, CLASSIC, BREAKDANCE
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package nl.agilecc.agiledancers.model.specifications;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
public enum DanceGroupType {
|
||||
SHOW_CASE(3),
|
||||
PRE_EDUCATION(1), // TODO check if this is one group mentioned with different docent each time, or three different groups - and thus if they are required 1 day or 3
|
||||
SELECTION(2),
|
||||
REGULAR(2),
|
||||
PRE_SCHOOLER(1);
|
||||
|
||||
@Getter
|
||||
private int dayPartsRequired;
|
||||
|
||||
DanceGroupType(int dayPartsRequired) {
|
||||
this.dayPartsRequired = dayPartsRequired;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.model.specifications;
|
||||
|
||||
public enum DayPartType {
|
||||
MORNING, AFTERNOON, EVENING;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.AbstractEntity;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public abstract class AbstractRepository<T extends AbstractEntity> {
|
||||
private long sequence = 0L;
|
||||
|
||||
protected Map<Long, T> entities = new LinkedHashMap<>();
|
||||
|
||||
public long nextId() {
|
||||
return sequence++;
|
||||
}
|
||||
|
||||
public T add(T entity) {
|
||||
if (entity.getId() == null) {
|
||||
entity.setId(nextId());
|
||||
}
|
||||
entities.put(entity.getId(), entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public List<T> findAll() {
|
||||
return List.copyOf(entities.values());
|
||||
}
|
||||
|
||||
public T findById(long id) {
|
||||
return entities.get(id);
|
||||
}
|
||||
public void delete(long id) {
|
||||
entities.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
|
||||
public class CoachRepository extends AbstractRepository<Coach> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
|
||||
public class DanceGroupRepository extends AbstractRepository<DanceGroup> {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
|
||||
public class DanceSchoolRepository extends AbstractRepository<DanceSchool> {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
|
||||
public class DancerRepository extends AbstractRepository<Dancer> {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
|
||||
public class DayPartRepository extends AbstractRepository<DayPart> {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package nl.agilecc.agiledancers.repository;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
|
||||
public class ShowRepository extends AbstractRepository<Show> {
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
|
||||
|
||||
@Path("/api/dance-schools/{schoolId}/dance-groups")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class DanceGroupResource {
|
||||
@Inject
|
||||
SchoolManagementService service;
|
||||
|
||||
@PathParam("schoolId")
|
||||
long schoolId;
|
||||
|
||||
@GET
|
||||
public List<GroupView> list() {
|
||||
return service.groups(schoolId).stream().map(GroupView::of).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public GroupView get(@PathParam("id") long id) {
|
||||
return GroupView.of(service.group(schoolId, id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(@NotNull @Valid GroupRequest request, @Context UriInfo uriInfo) {
|
||||
var entity = service.createGroup(schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
||||
.entity(GroupView.of(entity)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public GroupView update(@PathParam("id") long id, @NotNull @Valid GroupRequest request) {
|
||||
return GroupView.of(service.updateGroup(schoolId, id, request.category(), request.type(), request.startAge(), request.endAge(), request.styles()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") long id) {
|
||||
service.deleteGroup(schoolId, id);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/dancers/{dancerId}")
|
||||
public Response addDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
|
||||
service.addGroupDancer(schoolId, id, dancerId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}/dancers/{dancerId}")
|
||||
public Response removeDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
|
||||
service.removeGroupDancer(schoolId, id, dancerId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
||||
|
||||
@Path("/api/dance-schools")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class DanceSchoolResource {
|
||||
@Inject
|
||||
SchoolManagementService service;
|
||||
|
||||
@GET
|
||||
public List<SchoolView> list() {
|
||||
return service.schools().stream().map(SchoolView::of).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public SchoolView get(@PathParam("id") long id) {
|
||||
return SchoolView.of(service.school(id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(@NotNull @Valid SchoolRequest request, @Context UriInfo uriInfo) {
|
||||
var entity = service.createSchool(request.name());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
||||
.entity(SchoolView.of(entity)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public SchoolView update(@PathParam("id") long id, @NotNull @Valid SchoolRequest request) {
|
||||
return SchoolView.of(service.updateSchool(id, request.name()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") long id) {
|
||||
service.deleteSchool(id);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
|
||||
|
||||
@Path("/api/dance-schools/{schoolId}/dancers")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class DancerResource {
|
||||
@Inject
|
||||
SchoolManagementService service;
|
||||
|
||||
@PathParam("schoolId")
|
||||
long schoolId;
|
||||
|
||||
@GET
|
||||
public List<DancerView> list() {
|
||||
return service.dancers(schoolId).stream().map(DancerView::of).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public DancerView get(@PathParam("id") long id) {
|
||||
return DancerView.of(service.dancer(schoolId, id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) {
|
||||
var entity = service.createDancer(schoolId, request.name(), request.birthDate());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
||||
.entity(DancerView.of(entity)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public DancerView update(@PathParam("id") long id, @NotNull @Valid DancerRequest request) {
|
||||
return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") long id) {
|
||||
service.deleteDancer(schoolId, id);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.ext.ExceptionMapper;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ErrorView;
|
||||
|
||||
@Provider
|
||||
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
|
||||
@Override
|
||||
public Response toResponse(ManagementException exception) {
|
||||
int status = switch (exception.getKind()) {
|
||||
case NOT_FOUND -> 404;
|
||||
case INVALID_INPUT -> 400;
|
||||
};
|
||||
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.entity(new ErrorView(exception.getMessage())).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import com.fasterxml.jackson.databind.*;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.*;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
|
||||
|
||||
@Path("/api/dance-schools")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class SchoolSnapshotResource {
|
||||
@Inject SchoolManagementService service;
|
||||
@Inject ObjectMapper mapper;
|
||||
|
||||
@GET @Path("/{id}/export")
|
||||
public Response export(@PathParam("id") long id) throws Exception {
|
||||
String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(service.exportSchool(id));
|
||||
return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build();
|
||||
}
|
||||
@POST @Path("/import/preview") @Consumes(MediaType.APPLICATION_JSON)
|
||||
public SchoolManagementService.ImportPreview preview(String json) { return service.preview(parse(json)); }
|
||||
|
||||
@POST @Path("/import") @Consumes(MediaType.APPLICATION_JSON)
|
||||
public SchoolView apply(String json, @QueryParam("replaceId") Long replaceId) {
|
||||
return SchoolView.of(service.importSchool(parse(json), replaceId));
|
||||
}
|
||||
private SchoolSnapshot parse(String json) {
|
||||
try {
|
||||
SchoolSnapshot snapshot = mapper.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
|
||||
.readValue(json, SchoolSnapshot.class);
|
||||
if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school");
|
||||
return snapshot;
|
||||
} catch (Exception e) {
|
||||
throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
||||
|
||||
@Path("/api/dance-schools/{schoolId}/shows")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class ShowResource {
|
||||
@Inject
|
||||
SchoolManagementService service;
|
||||
|
||||
@PathParam("schoolId")
|
||||
long schoolId;
|
||||
|
||||
@GET
|
||||
public List<ShowView> list() {
|
||||
return service.shows(schoolId).stream().map(ShowView::of).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public ShowView get(@PathParam("id") long id) {
|
||||
return ShowView.of(service.show(schoolId, id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
|
||||
var entity = service.createShow(schoolId, request.name(), request.date());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
||||
.entity(ShowView.of(entity)).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) {
|
||||
return ShowView.of(service.updateShow(schoolId, id, request.name(), request.date()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") long id) {
|
||||
service.deleteShow(schoolId, id);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PastOrPresent;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record DancerRequest(@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record DancerView(long id, String name, LocalDate birthDate) {
|
||||
public static DancerView of(Dancer dancer) {
|
||||
return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
import java.util.List;
|
||||
|
||||
public record DayPartView(DayPartType type, List<Long> danceGroupIds) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
@RegisterForReflection
|
||||
public record ErrorView(String message) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import java.util.Set;
|
||||
|
||||
public record GroupRequest(@NotBlank String category, @NotNull DanceGroupType type,
|
||||
@NotNull @Min(0) Integer startAge, @NotNull @Min(0) Integer endAge,
|
||||
@NotNull Set<@NotNull DanceGroupStyle> styles) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public record GroupView(long id, String category, DanceGroupType type, int startAge, int endAge,
|
||||
Set<DanceGroupStyle> styles, List<Long> dancerIds) {
|
||||
public static GroupView of(DanceGroup group) {
|
||||
return new GroupView(group.getId(), group.getCategory(), group.getType(), group.getStartAge(),
|
||||
group.getEndAge(), Set.copyOf(group.getStyles()),
|
||||
group.getDancers().stream().map(Dancer::getId).toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record SchoolRequest(@NotBlank String name) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import java.util.List;
|
||||
|
||||
public record SchoolView(long id, String name, List<Long> dancerIds,
|
||||
List<Long> danceGroupIds, List<Long> showIds) {
|
||||
public static SchoolView of(DanceSchool school) {
|
||||
return new SchoolView(school.getId(), school.getName(),
|
||||
school.getDancers().stream().map(Dancer::getId).toList(),
|
||||
school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
|
||||
school.getShows().stream().map(Show::getId).toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record ShowRequest(@NotBlank String name, @NotNull LocalDate date) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record ShowView(long id, String name, LocalDate date, List<DayPartView> dayParts) {
|
||||
public static ShowView of(Show show) {
|
||||
return new ShowView(show.getId(), show.getName(), show.getDate(), show.getDayParts().stream()
|
||||
.map(dayPart -> new DayPartView(dayPart.getType(), dayPart.getDanceGroups().stream()
|
||||
.map(DanceGroup::getId).toList())).toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.repository.CoachRepository;
|
||||
|
||||
public class CoachService {
|
||||
private CoachRepository coachRepository = new CoachRepository();
|
||||
|
||||
public Coach addCoach(String name, Coach coach) {
|
||||
return coachRepository.add(Coach.builder().name(name).build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public class DanceGroupService {
|
||||
private final DanceGroupRepository danceGroupRepository = new DanceGroupRepository();
|
||||
|
||||
public DanceGroup add(String category, DanceGroupType type, int startAge, int endAge, Set<Dancer> dancers, Set<DanceGroupStyle> styles, Coach coach) {
|
||||
DanceGroup danceGroup = DanceGroup.builder()
|
||||
.category(category)
|
||||
.type(type)
|
||||
.startAge(startAge)
|
||||
.endAge(endAge)
|
||||
.dancers(dancers)
|
||||
.styles(styles)
|
||||
.coach(coach).build();
|
||||
|
||||
return danceGroupRepository.add(danceGroup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.*;
|
||||
import nl.agilecc.agiledancers.repository.DanceSchoolRepository;
|
||||
|
||||
public class DanceSchoolService {
|
||||
private final DanceSchoolRepository danceSchoolRepository = new DanceSchoolRepository();
|
||||
|
||||
public DanceSchool add(String name) {
|
||||
DanceSchool danceSchool = DanceSchool.builder().name(name).build();
|
||||
return danceSchoolRepository.add(danceSchool);
|
||||
}
|
||||
|
||||
public void addDancer(Long danceSchoolId, Dancer dancer) {
|
||||
danceSchoolRepository.findById(danceSchoolId).addDancer(dancer);
|
||||
}
|
||||
|
||||
public void addDanceGroup(Long danceSchoolId, DanceGroup danceGroup) {
|
||||
danceSchoolRepository.findById(danceSchoolId).addDanceGroup(danceGroup);
|
||||
}
|
||||
|
||||
public void addCoach(Long danceSchoolId, Coach coach) {
|
||||
danceSchoolRepository.findById(danceSchoolId).addCoach(coach);
|
||||
}
|
||||
|
||||
public void addShow(Long danceSchoolId, Show show) {
|
||||
danceSchoolRepository.findById(danceSchoolId).addShow(show);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.repository.DancerRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public class DancerService {
|
||||
private final DancerRepository dancerRepository = new DancerRepository();
|
||||
|
||||
public Dancer addDancer(String name, LocalDate birthDate) {
|
||||
Dancer dancer = Dancer.builder().name(name).birthDate(birthDate).build();
|
||||
return dancerRepository.add(dancer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
public class ManagementException extends RuntimeException {
|
||||
public enum Kind { NOT_FOUND, INVALID_INPUT }
|
||||
|
||||
private final Kind kind;
|
||||
|
||||
public ManagementException(Kind kind, String message) {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public Kind getKind() {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
import nl.agilecc.agiledancers.model.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.*;
|
||||
import nl.agilecc.agiledancers.repository.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
import static nl.agilecc.agiledancers.service.ManagementException.Kind.*;
|
||||
|
||||
/** Owns the in-memory school aggregates; all writes go through this shared service. */
|
||||
@Singleton
|
||||
public class SchoolManagementService {
|
||||
private final DanceSchoolRepository schools = new DanceSchoolRepository();
|
||||
private final DancerRepository dancers = new DancerRepository();
|
||||
private final DanceGroupRepository groups = new DanceGroupRepository();
|
||||
private final ShowRepository shows = new ShowRepository();
|
||||
|
||||
public synchronized List<DanceSchool> schools() {
|
||||
return schools.findAll();
|
||||
}
|
||||
|
||||
public synchronized DanceSchool school(long schoolId) {
|
||||
return required(schools.findById(schoolId), "Dance school", schoolId);
|
||||
}
|
||||
|
||||
public synchronized DanceSchool createSchool(String name) {
|
||||
return schools.add(DanceSchool.builder().name(name).build());
|
||||
}
|
||||
|
||||
public synchronized DanceSchool updateSchool(long schoolId, String name) {
|
||||
DanceSchool school = school(schoolId);
|
||||
school.setName(name);
|
||||
return school;
|
||||
}
|
||||
|
||||
public synchronized void deleteSchool(long schoolId) {
|
||||
DanceSchool school = school(schoolId);
|
||||
school.getDanceGroups().forEach(group -> groups.delete(group.getId()));
|
||||
school.getDancers().forEach(dancer -> dancers.delete(dancer.getId()));
|
||||
school.getShows().forEach(show -> shows.delete(show.getId()));
|
||||
schools.delete(schoolId);
|
||||
}
|
||||
|
||||
public synchronized List<Dancer> dancers(long schoolId) {
|
||||
return List.copyOf(school(schoolId).getDancers());
|
||||
}
|
||||
|
||||
public synchronized Dancer dancer(long schoolId, long dancerId) {
|
||||
return member(school(schoolId).getDancers(), dancerId, "Dancer");
|
||||
}
|
||||
|
||||
public synchronized Dancer createDancer(long schoolId, String name, LocalDate birthDate) {
|
||||
DanceSchool school = school(schoolId);
|
||||
Dancer dancer = dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
|
||||
school.addDancer(dancer);
|
||||
return dancer;
|
||||
}
|
||||
|
||||
public synchronized Dancer updateDancer(long schoolId, long dancerId, String name, LocalDate birthDate) {
|
||||
Dancer dancer = dancer(schoolId, dancerId);
|
||||
dancer.setName(name);
|
||||
dancer.setBirthDate(birthDate);
|
||||
return dancer;
|
||||
}
|
||||
|
||||
public synchronized void deleteDancer(long schoolId, long dancerId) {
|
||||
DanceSchool school = school(schoolId);
|
||||
Dancer dancer = dancer(schoolId, dancerId);
|
||||
school.getDanceGroups().forEach(group -> group.removeDancer(dancer));
|
||||
school.removeDancer(dancer);
|
||||
dancers.delete(dancerId);
|
||||
}
|
||||
|
||||
public synchronized List<DanceGroup> groups(long schoolId) {
|
||||
return List.copyOf(school(schoolId).getDanceGroups());
|
||||
}
|
||||
|
||||
public synchronized DanceGroup group(long schoolId, long groupId) {
|
||||
return member(school(schoolId).getDanceGroups(), groupId, "Dance group");
|
||||
}
|
||||
|
||||
public synchronized DanceGroup createGroup(long schoolId, String category, DanceGroupType type,
|
||||
int startAge, int endAge, Set<DanceGroupStyle> styles) {
|
||||
DanceSchool school = school(schoolId);
|
||||
validateAgeRange(startAge, endAge);
|
||||
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
|
||||
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
|
||||
school.addDanceGroup(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
public synchronized DanceGroup updateGroup(long schoolId, long groupId, String category, DanceGroupType type,
|
||||
int startAge, int endAge, Set<DanceGroupStyle> styles) {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
validateAgeRange(startAge, endAge);
|
||||
group.setCategory(category);
|
||||
group.setType(type);
|
||||
group.setStartAge(startAge);
|
||||
group.setEndAge(endAge);
|
||||
group.replaceStyles(styles);
|
||||
return group;
|
||||
}
|
||||
|
||||
public synchronized void deleteGroup(long schoolId, long groupId) {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group)));
|
||||
school(schoolId).removeDanceGroup(group);
|
||||
groups.delete(groupId);
|
||||
}
|
||||
|
||||
public synchronized void addGroupDancer(long schoolId, long groupId, long dancerId) {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
group.addDancer(dancer(schoolId, dancerId));
|
||||
}
|
||||
|
||||
public synchronized void removeGroupDancer(long schoolId, long groupId, long dancerId) {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
group.removeDancer(dancer(schoolId, dancerId));
|
||||
}
|
||||
|
||||
public synchronized List<Show> shows(long schoolId) {
|
||||
return List.copyOf(school(schoolId).getShows());
|
||||
}
|
||||
|
||||
public synchronized Show show(long schoolId, long showId) {
|
||||
return member(school(schoolId).getShows(), showId, "Show");
|
||||
}
|
||||
|
||||
public synchronized Show createShow(long schoolId, String name, LocalDate date) {
|
||||
DanceSchool school = school(schoolId);
|
||||
List<DayPart> dayParts = Arrays.stream(DayPartType.values())
|
||||
.map(type -> DayPart.builder().type(type).danceGroups(List.of()).build()).toList();
|
||||
Show show = shows.add(Show.builder().name(name).date(date).dayParts(dayParts).build());
|
||||
school.addShow(show);
|
||||
return show;
|
||||
}
|
||||
|
||||
public synchronized Show updateShow(long schoolId, long showId, String name, LocalDate date) {
|
||||
Show show = show(schoolId, showId);
|
||||
show.setName(name);
|
||||
show.setDate(date);
|
||||
return show;
|
||||
}
|
||||
|
||||
public synchronized void deleteShow(long schoolId, long showId) {
|
||||
Show show = show(schoolId, showId);
|
||||
school(schoolId).removeShow(show);
|
||||
shows.delete(showId);
|
||||
}
|
||||
|
||||
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}
|
||||
|
||||
public synchronized SchoolSnapshot exportSchool(long id) {
|
||||
return SchoolSnapshot.of(school(id));
|
||||
}
|
||||
|
||||
public synchronized ImportPreview preview(SchoolSnapshot snapshot) {
|
||||
snapshot.restore();
|
||||
DanceSchool existing = schools.findAll().stream().filter(s -> s.getKey().equals(snapshot.key())).findFirst().orElse(null);
|
||||
return new ImportPreview(snapshot.name(), snapshot.dancers().size(), snapshot.groups().size(), snapshot.shows().size(),
|
||||
existing == null ? null : existing.getId(), existing == null ? null : existing.getName());
|
||||
}
|
||||
|
||||
public synchronized DanceSchool importSchool(SchoolSnapshot snapshot, Long replaceId) {
|
||||
DanceSchool staged = snapshot.restore();
|
||||
ImportPreview preview = preview(snapshot);
|
||||
if (!Objects.equals(preview.existingId(), replaceId)) {
|
||||
throw new ManagementException(INVALID_INPUT, "De bestaande school is gewijzigd. Bekijk de import opnieuw.");
|
||||
}
|
||||
// All validation and construction finish before changing repository state.
|
||||
// Rebuild sets after assigning runtime IDs: entity hash codes include the ID.
|
||||
List<Dancer> importedDancers = new ArrayList<>(staged.getDancers());
|
||||
List<DanceGroup> importedGroups = new ArrayList<>(staged.getDanceGroups());
|
||||
List<Show> importedShows = new ArrayList<>(staged.getShows());
|
||||
Map<DanceGroup, List<Dancer>> memberships = new IdentityHashMap<>();
|
||||
importedGroups.forEach(g -> memberships.put(g, new ArrayList<>(g.getDancers())));
|
||||
importedDancers.forEach(d -> d.setId(dancers.nextId()));
|
||||
importedGroups.forEach(g -> {
|
||||
g.setId(groups.nextId());
|
||||
});
|
||||
List<DanceGroup> rebuiltGroups = new ArrayList<>();
|
||||
Map<DanceGroup, DanceGroup> replacements = new IdentityHashMap<>();
|
||||
for (DanceGroup g : importedGroups) {
|
||||
DanceGroup rebuilt = DanceGroup.builder().category(g.getCategory()).type(g.getType())
|
||||
.startAge(g.getStartAge()).endAge(g.getEndAge()).styles(new LinkedHashSet<>(g.getStyles()))
|
||||
.coach(g.getCoach()).dancers(new LinkedHashSet<>(memberships.get(g))).build();
|
||||
rebuilt.setId(g.getId()); rebuiltGroups.add(rebuilt); replacements.put(g, rebuilt);
|
||||
}
|
||||
List<Show> rebuiltShows = new ArrayList<>();
|
||||
for (Show show : importedShows) {
|
||||
Show rebuilt = Show.builder().name(show.getName()).date(show.getDate()).dayParts(show.getDayParts().stream()
|
||||
.map(p -> DayPart.builder().type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
|
||||
rebuilt.setId(shows.nextId()); rebuiltShows.add(rebuilt);
|
||||
}
|
||||
DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName())
|
||||
.dancers(new LinkedHashSet<>(importedDancers)).danceGroups(new LinkedHashSet<>(rebuiltGroups))
|
||||
.shows(new LinkedHashSet<>(rebuiltShows)).coaches(new LinkedHashSet<>(staged.getCoaches())).build();
|
||||
if (replaceId != null) { deleteSchool(replaceId); imported.setId(replaceId); }
|
||||
importedDancers.forEach(dancers::add);
|
||||
rebuiltGroups.forEach(groups::add);
|
||||
rebuiltShows.forEach(shows::add);
|
||||
return schools.add(imported);
|
||||
}
|
||||
|
||||
private static void validateAgeRange(int startAge, int endAge) {
|
||||
if (startAge > endAge) {
|
||||
throw new ManagementException(INVALID_INPUT, "startAge must not exceed endAge");
|
||||
}
|
||||
}
|
||||
|
||||
private static <T extends AbstractEntity> T member(Collection<T> entities, long id, String type) {
|
||||
return entities.stream().filter(entity -> entity.getId() == id).findFirst()
|
||||
.orElseThrow(() -> new ManagementException(NOT_FOUND, type + " " + id + " not found in this school"));
|
||||
}
|
||||
|
||||
private static <T> T required(T value, String type, long id) {
|
||||
if (value == null) {
|
||||
throw new ManagementException(NOT_FOUND, type + " " + id + " not found");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.*;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
/** Version 1 file format. IDs are local to the file, never server identifiers. */
|
||||
public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<Person> dancers,
|
||||
List<Trainer> coaches, List<Group> groups, List<Performance> shows) {
|
||||
public record Person(String id, String name, LocalDate birthDate) {}
|
||||
public record Trainer(String id, String name) {}
|
||||
public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge,
|
||||
List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}
|
||||
public record Part(DayPartType type, List<String> groupIds) {}
|
||||
public record Performance(String id, String name, LocalDate date, List<Part> dayParts) {}
|
||||
|
||||
public static SchoolSnapshot of(DanceSchool s) {
|
||||
Map<Dancer, String> dancers = ids(s.getDancers());
|
||||
Map<Coach, String> coaches = ids(s.getCoaches());
|
||||
Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
||||
Map<Show, String> shows = ids(s.getShows());
|
||||
return new SchoolSnapshot(1, s.getKey(), s.getName(),
|
||||
s.getDancers().stream().map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())).toList(),
|
||||
s.getCoaches().stream().map(c -> new Trainer(coaches.get(c), c.getName())).toList(),
|
||||
s.getDanceGroups().stream().map(g -> new Group(groups.get(g), g.getCategory(), g.getType(),
|
||||
g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()),
|
||||
g.getDancers().stream().map(dancers::get).toList(),
|
||||
g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(),
|
||||
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(), show.getDate(),
|
||||
show.getDayParts().stream().map(p -> new Part(p.getType(),
|
||||
p.getDanceGroups().stream().map(groups::get).toList())).toList())).toList());
|
||||
}
|
||||
|
||||
private static <T> Map<T, String> ids(Collection<T> values) {
|
||||
Map<T, String> result = new IdentityHashMap<>();
|
||||
for (T value : values) result.put(value, Integer.toString(result.size() + 1));
|
||||
return result;
|
||||
}
|
||||
|
||||
public DanceSchool restore() {
|
||||
check(Objects.equals(formatVersion, 1), "Niet-ondersteunde formatVersion: " + formatVersion);
|
||||
check(key != null, "key ontbreekt"); text(name, "schoolnaam");
|
||||
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
||||
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
|
||||
for (Person d : list(dancers, "dancers")) {
|
||||
check(d != null, "Lege danser"); text(d.name, "dansernaam");
|
||||
check(d.birthDate != null && !d.birthDate.isAfter(LocalDate.now()), "Ongeldige geboortedatum voor " + d.name);
|
||||
Dancer dancer = Dancer.builder().name(d.name).birthDate(d.birthDate).build();
|
||||
// Assign temporary distinct IDs before inserting entities into sets.
|
||||
dancer.setId((long) dancerMap.size());
|
||||
put(dancerMap, d.id, dancer); school.addDancer(dancer);
|
||||
}
|
||||
Map<String, Coach> coachMap = new LinkedHashMap<>();
|
||||
for (Trainer c : list(coaches, "coaches")) {
|
||||
check(c != null, "Lege coach"); text(c.name, "coachnaam");
|
||||
Coach coach = Coach.builder().name(c.name).build(); coach.setId((long) coachMap.size());
|
||||
put(coachMap, c.id, coach); school.addCoach(coach);
|
||||
}
|
||||
Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
|
||||
for (Group g : list(groups, "groups")) {
|
||||
check(g != null, "Lege groep"); text(g.category, "groepsnaam");
|
||||
check(g.type != null && g.startAge != null && g.endAge != null && g.startAge >= 0 && g.endAge >= g.startAge,
|
||||
"Ongeldig type of leeftijdsbereik voor groep " + g.category);
|
||||
DanceGroup group = DanceGroup.builder().category(g.category).type(g.type).startAge(g.startAge).endAge(g.endAge)
|
||||
.styles(new LinkedHashSet<>(list(g.styles, "styles"))).coach(g.coachId == null ? null : ref(coachMap, g.coachId, "groep " + g.category + " verwijst naar een onbekende coach" )).build();
|
||||
check(!group.getStyles().contains(null), "Ongeldige dansstijl");
|
||||
group.setId((long) groupMap.size());
|
||||
for (String id : unique(g.dancerIds, "dancerIds")) group.addDancer(ref(dancerMap, id, "groep " + g.category + " verwijst naar een onbekende danser"));
|
||||
put(groupMap, g.id, group); school.addDanceGroup(group);
|
||||
}
|
||||
Set<String> showIds = new HashSet<>();
|
||||
for (Performance show : list(shows, "shows")) {
|
||||
check(show != null, "Lege show"); text(show.id, "show-id");
|
||||
check(showIds.add(show.id), "Dubbele show-id " + show.id); text(show.name, "shownaam");
|
||||
check(show.date != null, "Showdatum ontbreekt voor " + show.name);
|
||||
List<DayPart> parts = new ArrayList<>(); Set<DayPartType> types = new HashSet<>();
|
||||
for (Part part : list(show.dayParts, "dayParts")) {
|
||||
check(part != null && part.type != null && types.add(part.type), "Ongeldig of dubbel dagdeel voor " + show.name);
|
||||
parts.add(DayPart.builder().type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream()
|
||||
.map(id -> ref(groupMap, id, "Show " + show.name + " verwijst naar een onbekende groep")).toList()).build());
|
||||
}
|
||||
Show entity = Show.builder().name(show.name).date(show.date).dayParts(parts).build();
|
||||
entity.setId((long) showIds.size()); school.addShow(entity);
|
||||
}
|
||||
return school;
|
||||
}
|
||||
private static void text(String value, String field) { check(value != null && !value.isBlank(), field + " ontbreekt"); }
|
||||
private static <T> List<T> list(List<T> value, String field) { check(value != null, field + " ontbreekt"); return value; }
|
||||
private static List<String> unique(List<String> value, String field) {
|
||||
list(value, field); check(value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(), "Ongeldige of dubbele verwijzing in " + field); return value;
|
||||
}
|
||||
private static <T> void put(Map<String, T> map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); }
|
||||
private static <T> T ref(Map<String,T> map, String id, String error) { T value = map.get(id); check(value != null, error); return value; }
|
||||
private static void check(boolean valid, String message) { if (!valid) throw new ManagementException(ManagementException.Kind.INVALID_INPUT, message); }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
quarkus.application.name=agiledancers
|
||||
quarkus.http.host=0.0.0.0
|
||||
@@ -0,0 +1,77 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||
import com.tngtech.archunit.junit.ArchTest;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
|
||||
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
|
||||
|
||||
@AnalyzeClasses(packages = "nl.agilecc.agiledancers", importOptions = ImportOption.DoNotIncludeTests.class)
|
||||
class ArchitectureTest {
|
||||
private static final String ROOT = "nl.agilecc.agiledancers";
|
||||
private static final String API_MODEL = ROOT + ".rest.apimodel";
|
||||
|
||||
// API model classes and records use the Request, Response, View, Dto or DTO suffix.
|
||||
@ArchTest
|
||||
static final ArchRule api_models_reside_in_the_api_model_package = classes()
|
||||
.that().haveNameMatching(".*(Request|Response|View|Dto|DTO)$")
|
||||
.should().resideInAPackage(API_MODEL);
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule rest_records_reside_in_the_api_model_package = classes()
|
||||
.that().resideInAPackage(ROOT + ".rest..")
|
||||
.and().areRecords()
|
||||
.should().resideInAPackage(API_MODEL);
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule api_models_are_public_top_level_types = classes()
|
||||
.that().resideInAPackage(API_MODEL + "..")
|
||||
.should().bePublic()
|
||||
.andShould().beTopLevelClasses()
|
||||
.andShould().haveNameMatching(".*(Request|Response|View|Dto|DTO)$");
|
||||
|
||||
// The existing services depend on concrete repositories. This rule preserves that
|
||||
// arrangement until repository ports and dependency injection are introduced.
|
||||
@ArchTest
|
||||
static final ArchRule layer_dependencies = layeredArchitecture()
|
||||
.consideringOnlyDependenciesInLayers()
|
||||
.layer("Model").definedBy(ROOT + ".model..")
|
||||
.layer("Services").definedBy(ROOT + ".service..")
|
||||
.layer("Repositories").definedBy(ROOT + ".repository..")
|
||||
.optionalLayer("REST").definedBy(ROOT + ".rest..")
|
||||
.optionalLayer("Solver").definedBy(ROOT + ".solver..")
|
||||
.whereLayer("Model").mayNotAccessAnyLayer()
|
||||
.whereLayer("Services").mayOnlyAccessLayers("Model", "Repositories")
|
||||
.whereLayer("Repositories").mayOnlyAccessLayers("Model")
|
||||
.whereLayer("REST").mayOnlyAccessLayers("Services", "Model")
|
||||
.whereLayer("Solver").mayOnlyAccessLayers("Services", "Model");
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule production_classes_have_an_architectural_home = classes()
|
||||
.should().resideInAnyPackage(ROOT + ".model..", ROOT + ".service..",
|
||||
ROOT + ".repository..", ROOT + ".rest..", ROOT + ".solver..");
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule model_is_independent_of_frameworks = classes()
|
||||
.that().resideInAPackage(ROOT + ".model..")
|
||||
.should().onlyDependOnClassesThat()
|
||||
.resideInAnyPackage(ROOT + ".model..", "java..", "lombok..");
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule ortools_is_encapsulated_in_solver = noClasses()
|
||||
.that().resideOutsideOfPackage(ROOT + ".solver..")
|
||||
.should().dependOnClassesThat().resideInAPackage("com.google.ortools..");
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule http_api_is_encapsulated_in_rest = noClasses()
|
||||
.that().resideOutsideOfPackage(ROOT + ".rest..")
|
||||
.should().dependOnClassesThat().resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy..");
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule packages_are_free_of_cycles = slices()
|
||||
.matching(ROOT + ".(*)..").should().beFreeOfCycles();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
|
||||
@QuarkusTest
|
||||
class HealthTest {
|
||||
@Test
|
||||
void applicationIsReady() {
|
||||
given()
|
||||
.when().get("/q/health/ready")
|
||||
.then().statusCode(200)
|
||||
.body("status", equalTo("UP"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.response.ValidatableResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
@QuarkusTest
|
||||
class SchoolManagementResourceTest {
|
||||
private static final String SCHOOLS = "/api/dance-schools";
|
||||
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
|
||||
private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR",
|
||||
"startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"});
|
||||
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "date", "2027-06-12");
|
||||
|
||||
@Test
|
||||
void managesSchoolAndItsResources() {
|
||||
long schoolId = create(SCHOOLS, Map.of("name", "Dance Studio"));
|
||||
String school = SCHOOLS + "/" + schoolId;
|
||||
try {
|
||||
given().get(school).then().statusCode(200).body("name", equalTo("Dance Studio"));
|
||||
given().get(SCHOOLS).then().statusCode(200).body("id", hasItem((int) schoolId));
|
||||
put(school, Map.of("name", "Renamed studio")).statusCode(200).body("name", equalTo("Renamed studio"));
|
||||
|
||||
long dancerId = create(school + "/dancers", DANCER);
|
||||
String dancer = school + "/dancers/" + dancerId;
|
||||
put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13"))
|
||||
.statusCode(200).body("birthDate", equalTo("2011-06-13"));
|
||||
given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated"));
|
||||
given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId));
|
||||
|
||||
long groupId = create(school + "/dance-groups", GROUP);
|
||||
String group = school + "/dance-groups/" + groupId;
|
||||
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
|
||||
put(group, Map.of("category", "Selection", "type", "SELECTION", "startAge", 10,
|
||||
"endAge", 20, "styles", new String[]{"JAZZ", "MODERN"}))
|
||||
.statusCode(200).body("category", equalTo("Selection"))
|
||||
.body("dancerIds", contains((int) dancerId)).body("styles", containsInAnyOrder("JAZZ", "MODERN"));
|
||||
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
|
||||
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
|
||||
|
||||
long showId = create(school + "/shows", SHOW);
|
||||
String show = school + "/shows/" + showId;
|
||||
given().get(show).then().statusCode(200)
|
||||
.body("dayParts.type", contains("MORNING", "AFTERNOON", "EVENING"))
|
||||
.body("dayParts.danceGroupIds", everyItem(empty()));
|
||||
put(show, Map.of("name", "Summer show", "date", "2027-07-01"))
|
||||
.statusCode(200).body("date", equalTo("2027-07-01"));
|
||||
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
|
||||
given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId))
|
||||
.body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId));
|
||||
|
||||
for (String path : new String[]{dancer, group, show}) {
|
||||
given().delete(path).then().statusCode(204);
|
||||
given().get(path).then().statusCode(404);
|
||||
given().delete(path).then().statusCode(404);
|
||||
}
|
||||
given().get(school).then().body("dancerIds", empty()).body("danceGroupIds", empty()).body("showIds", empty());
|
||||
} finally {
|
||||
given().delete(school).then().statusCode(204);
|
||||
}
|
||||
given().get(school).then().statusCode(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() {
|
||||
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
|
||||
try {
|
||||
long dancerId = create(school + "/dancers", DANCER);
|
||||
String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
|
||||
String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
|
||||
for (String group : new String[]{first, second}) {
|
||||
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
|
||||
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
|
||||
given().get(group).then().body("dancerIds", contains((int) dancerId));
|
||||
}
|
||||
given().delete(first + "/dancers/" + dancerId).then().statusCode(204);
|
||||
given().delete(first + "/dancers/" + dancerId).then().statusCode(204);
|
||||
given().get(first).then().body("dancerIds", empty());
|
||||
given().get(second).then().body("dancerIds", contains((int) dancerId));
|
||||
given().put(first + "/dancers/" + dancerId).then().statusCode(204);
|
||||
given().delete(school + "/dancers/" + dancerId).then().statusCode(204);
|
||||
given().get(first).then().body("dancerIds", empty());
|
||||
given().get(second).then().body("dancerIds", empty());
|
||||
} finally {
|
||||
given().delete(school).then().statusCode(204);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void isolatesSchoolsAndCascadesSchoolDeletion() {
|
||||
String first = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "First"));
|
||||
String second = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Second"));
|
||||
try {
|
||||
long dancer = create(first + "/dancers", DANCER);
|
||||
long group = create(first + "/dance-groups", GROUP);
|
||||
long show = create(first + "/shows", SHOW);
|
||||
long otherGroup = create(second + "/dance-groups", GROUP);
|
||||
for (String path : new String[]{"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) {
|
||||
given().get(second + path).then().statusCode(404);
|
||||
given().delete(second + path).then().statusCode(404);
|
||||
}
|
||||
put(second + "/dancers/" + dancer, DANCER).statusCode(404);
|
||||
put(second + "/dance-groups/" + group, GROUP).statusCode(404);
|
||||
put(second + "/shows/" + show, SHOW).statusCode(404);
|
||||
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer).then().statusCode(404);
|
||||
given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
|
||||
given().delete(first).then().statusCode(204);
|
||||
given().get(first + "/dancers").then().statusCode(404);
|
||||
given().get(first + "/dance-groups").then().statusCode(404);
|
||||
given().get(first + "/shows").then().statusCode(404);
|
||||
given().get(second).then().statusCode(200);
|
||||
} finally {
|
||||
given().delete(first);
|
||||
given().delete(second).then().statusCode(204);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidInputWithoutChangingStoredData() {
|
||||
given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400);
|
||||
given().contentType("application/json").body("null").post(SCHOOLS).then().statusCode(400);
|
||||
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school"));
|
||||
try {
|
||||
put(school, Map.of("name", " ")).statusCode(400);
|
||||
given().get(school).then().body("name", equalTo("Validation school"));
|
||||
for (Object body : new Object[]{Map.of("name", "Sam"), Map.of("name", "Sam", "birthDate", "2999-01-01"),
|
||||
Map.of("name", "Sam", "birthDate", "not-a-date")}) {
|
||||
given().contentType("application/json").body(body).post(school + "/dancers").then().statusCode(400);
|
||||
}
|
||||
for (String body : new String[]{"{}", "{\"category\":\"Teens\",\"type\":\"INVALID\"}",
|
||||
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
|
||||
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"}) {
|
||||
given().contentType("application/json").body(body).post(school + "/dance-groups").then().statusCode(400);
|
||||
}
|
||||
long group = create(school + "/dance-groups", GROUP);
|
||||
put(school + "/dance-groups/" + group, Map.of("category", "Invalid", "type", "REGULAR",
|
||||
"startAge", 20, "endAge", 10, "styles", new String[]{})).statusCode(400);
|
||||
given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens"));
|
||||
given().contentType("application/json").body(Map.of("name", "Show"))
|
||||
.post(school + "/shows").then().statusCode(400);
|
||||
given().get(school + "/dancers").then().body("$", empty());
|
||||
given().get(school + "/shows").then().body("$", empty());
|
||||
given().contentType("application/json").body(DANCER)
|
||||
.post(SCHOOLS + "/9223372036854775807/dancers").then().statusCode(404);
|
||||
} finally {
|
||||
given().delete(school).then().statusCode(204);
|
||||
}
|
||||
}
|
||||
|
||||
private static long create(String path, Object body) {
|
||||
return given().contentType("application/json").body(body).post(path).then()
|
||||
.statusCode(201).header("Location", matchesPattern(".*/[0-9]+$"))
|
||||
.extract().jsonPath().getLong("id");
|
||||
}
|
||||
|
||||
private static ValidatableResponse put(String path, Object body) {
|
||||
return given().contentType("application/json").body(body).put(path).then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
@QuarkusTest
|
||||
class SchoolSnapshotResourceTest {
|
||||
private static final String BASE = "/api/dance-schools";
|
||||
@Test void downloadPreviewRestoreAndRejectInvalidFiles() {
|
||||
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE)
|
||||
.then().statusCode(201).extract().jsonPath().getLong("id");
|
||||
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200)
|
||||
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(1)).extract().asString();
|
||||
try {
|
||||
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200)
|
||||
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0));
|
||||
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 1", "\"formatVersion\" : 2"),
|
||||
json.replace("\"name\" :", "\"unknown\" :"), json + " {}",
|
||||
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) {
|
||||
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)
|
||||
.then().statusCode(400).body("message", not(emptyOrNullString()));
|
||||
}
|
||||
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
|
||||
given().delete(BASE + "/" + id).then().statusCode(204);
|
||||
id = given().contentType("application/json").body(json).post(BASE + "/import")
|
||||
.then().statusCode(200).extract().jsonPath().getLong("id");
|
||||
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
|
||||
} finally { given().delete(BASE + "/" + id); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import nl.agilecc.agiledancers.service.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SchoolSnapshotTest {
|
||||
private SchoolSnapshot complete() {
|
||||
return new SchoolSnapshot(1, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))),
|
||||
List.of(new SchoolSnapshot.Trainer("c", "Alex")),
|
||||
List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
|
||||
new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
|
||||
List.of(new SchoolSnapshot.Performance("s", "Show", LocalDate.of(2027,1,1),
|
||||
List.of(new SchoolSnapshot.Part(DayPartType.MORNING, List.of("g", "h"))))));
|
||||
}
|
||||
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
|
||||
var snapshot = complete();
|
||||
var service = new SchoolManagementService();
|
||||
var existing = service.createSchool("Other");
|
||||
service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000,1,1));
|
||||
var imported = service.importSchool(snapshot, null);
|
||||
assertEquals(2, imported.getDanceGroups().size());
|
||||
var dancer = imported.getDancers().iterator().next();
|
||||
imported.getDanceGroups().forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
|
||||
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId()));
|
||||
var restarted = new SchoolManagementService();
|
||||
var restored = restarted.importSchool(service.exportSchool(imported.getId()), null);
|
||||
assertEquals(SchoolSnapshot.of(snapshot.restore()), restarted.exportSchool(restored.getId()));
|
||||
// Membership removal must work after remapping hash-based sets.
|
||||
service.deleteDancer(imported.getId(), dancer.getId());
|
||||
imported.getDanceGroups().forEach(g -> assertTrue(g.getDancers().isEmpty()));
|
||||
assertEquals(1, existing.getDancers().size());
|
||||
var group = imported.getDanceGroups().iterator().next();
|
||||
service.deleteGroup(imported.getId(), group.getId());
|
||||
assertEquals(1, imported.getShows().iterator().next().getDayParts().getFirst().getDanceGroups().size());
|
||||
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
|
||||
}
|
||||
@Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
|
||||
var service = new SchoolManagementService(); var snapshot = complete();
|
||||
assertNull(service.preview(snapshot).existingId()); assertTrue(service.schools().isEmpty());
|
||||
var original = service.importSchool(snapshot, null);
|
||||
service.createDancer(original.getId(), "Extra", LocalDate.of(2000,1,1));
|
||||
var other = service.createSchool(snapshot.name());
|
||||
assertEquals(original.getId(), service.preview(snapshot).existingId());
|
||||
assertThrows(ManagementException.class, () -> service.importSchool(snapshot, null));
|
||||
assertEquals(2, original.getDancers().size());
|
||||
var replaced = service.importSchool(snapshot, original.getId());
|
||||
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId()));
|
||||
assertSame(other, service.school(other.getId()));
|
||||
service.deleteSchool(replaced.getId());
|
||||
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
|
||||
}
|
||||
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
|
||||
var service = new SchoolManagementService(); var valid = complete();
|
||||
var original = service.importSchool(valid, null);
|
||||
var bad = new SchoolSnapshot(1, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
|
||||
assertThrows(ManagementException.class, () -> service.preview(bad));
|
||||
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
|
||||
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));
|
||||
}
|
||||
@Test void emptySchoolRoundTrips() {
|
||||
var service = new SchoolManagementService();
|
||||
var empty = service.createSchool("Empty"); var snapshot = service.exportSchool(empty.getId());
|
||||
service.deleteSchool(empty.getId());
|
||||
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
|
||||
}
|
||||
}
|
||||
26
agiledancers-be/src/test/java/sit/DanceSchoolSit.java
Normal file
26
agiledancers-be/src/test/java/sit/DanceSchoolSit.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package sit;
|
||||
|
||||
import net.datafaker.Faker;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.service.DanceSchoolService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DanceSchoolSit {
|
||||
Faker faker = new Faker();
|
||||
int numberOfDanceGroups = 42;
|
||||
|
||||
int aantalDansersTm5 = 10;
|
||||
int aantalDansers6tm10 = 30;
|
||||
int aantalDansers11tot14 = 70;
|
||||
int aantalDansers14tm17 = 140;
|
||||
int aantalDansers18Plus = 100;
|
||||
|
||||
private DanceSchoolService danceSchoolService = new DanceSchoolService();
|
||||
|
||||
@Test
|
||||
void testDanceSchoolModel() {
|
||||
DanceSchool danceSchool = danceSchoolService.add("24 Dance Studio");
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
7
agiledancers-fe/.dockerignore
Normal file
7
agiledancers-fe/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.angular
|
||||
.git
|
||||
test-results
|
||||
playwright-report
|
||||
|
||||
unit-results
|
||||
17
agiledancers-fe/.editorconfig
Normal file
17
agiledancers-fe/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
48
agiledancers-fe/.gitignore
vendored
Normal file
48
agiledancers-fe/.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Browser test artifacts
|
||||
/test-results
|
||||
/playwright-report
|
||||
1
agiledancers-fe/.nvmrc
Normal file
1
agiledancers-fe/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
22.22.3
|
||||
12
agiledancers-fe/.prettierrc
Normal file
12
agiledancers-fe/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
agiledancers-fe/.vscode/extensions.json
vendored
Normal file
4
agiledancers-fe/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
agiledancers-fe/.vscode/launch.json
vendored
Normal file
20
agiledancers-fe/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
agiledancers-fe/.vscode/tasks.json
vendored
Normal file
42
agiledancers-fe/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
18
agiledancers-fe/Dockerfile
Normal file
18
agiledancers-fe/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:24.15.0-bookworm AS build
|
||||
WORKDIR /workspace
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm test && npm run build
|
||||
|
||||
FROM nginx:1.28-alpine AS runtime
|
||||
ENV BACKEND_UPSTREAM=backend:8080
|
||||
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
EXPOSE 8080
|
||||
|
||||
# CI supplies the production output it has already built and tested.
|
||||
FROM runtime AS prebuilt
|
||||
COPY dist/agiledancers-fe/browser/ /usr/share/nginx/html/
|
||||
|
||||
FROM runtime AS standalone
|
||||
COPY --from=build /workspace/dist/agiledancers-fe/browser/ /usr/share/nginx/html/
|
||||
86
agiledancers-fe/README.md
Normal file
86
agiledancers-fe/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Agile Dancers frontend
|
||||
|
||||
Angular 22 application for managing dance schools through the existing Quarkus
|
||||
REST API.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 22.22.3 or newer in the 22.x line, Node.js 24.15.0 or newer in the 24.x
|
||||
line, or Node.js 26+. See [Angular version compatibility](https://angular.dev/reference/versions).
|
||||
- npm and the Java 25 / Maven backend setup described in the repository README.
|
||||
|
||||
An `.nvmrc` is included. With nvm installed:
|
||||
|
||||
```sh
|
||||
nvm install
|
||||
nvm use
|
||||
npm ci
|
||||
```
|
||||
|
||||
## Run locally
|
||||
|
||||
Start the backend from the repository root:
|
||||
|
||||
```sh
|
||||
mvn -pl agiledancers-be quarkus:dev
|
||||
```
|
||||
|
||||
In a second terminal, from `agiledancers-fe`:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm start
|
||||
```
|
||||
|
||||
Open <http://localhost:4200>. The Angular development proxy forwards `/api/**`
|
||||
to `http://127.0.0.1:8080`. Update `proxy.conf.json` to use another backend address.
|
||||
|
||||
## Screens
|
||||
|
||||
- School overview: list schools and create a school.
|
||||
- School dashboard: summary, quick actions, next show, rename and delete a school.
|
||||
- Dancers: create, edit, search and delete dancers; see their group memberships.
|
||||
- Groups: create, edit and delete groups; manage dancers in each group.
|
||||
- Shows: create, edit and delete shows; see the three automatically created day parts.
|
||||
|
||||
Deleting requires confirmation. Errors leave entered form values intact.
|
||||
Membership changes are saved immediately, and a dancer can belong to multiple
|
||||
groups. Assigning groups to show day parts is not available yet.
|
||||
|
||||
The backend currently stores data in memory, so restarting it clears the data.
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
Unit tests cover API URLs and payloads, ID zero, form validation, and save failures.
|
||||
The production build is written to `dist/agiledancers-fe/browser`.
|
||||
When deploying it, configure the web server to forward `/api/**` to Quarkus and
|
||||
serve `index.html` for application routes such as `/schools/0/overview`.
|
||||
The development proxy is not included in the production bundle.
|
||||
|
||||
## Browser tests
|
||||
|
||||
Keep the backend running on port 8080, then run:
|
||||
|
||||
```sh
|
||||
npx playwright install chromium
|
||||
npm run e2e
|
||||
```
|
||||
|
||||
Playwright starts the frontend on port 4200. The tests create a uniquely named
|
||||
school, exercise CRUD and multiple group memberships against the real backend,
|
||||
check mobile layout, and clean up the school afterwards. A second test checks
|
||||
connection failure and retry behavior. Tests use only their own school data.
|
||||
|
||||
To use an existing Chrome installation instead of downloading Chromium:
|
||||
|
||||
```sh
|
||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome npm run e2e
|
||||
```
|
||||
|
||||
Browser test screenshots and failure traces are written to the ignored
|
||||
`test-results` directory.
|
||||
72
agiledancers-fe/angular.json
Normal file
72
agiledancers-fe/angular.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"agiledancers-fe": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.css"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "agiledancers-fe:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "agiledancers-fe:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
agiledancers-fe/e2e/school-management.spec.ts
Normal file
162
agiledancers-fe/e2e/school-management.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080';
|
||||
let schoolToDelete: number | undefined;
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (schoolToDelete !== undefined) {
|
||||
await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`);
|
||||
schoolToDelete = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function tab(page: Page, label: string) {
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Dansschool onderdelen' })
|
||||
.getByRole('link', { name: new RegExp(`^${label}`) })
|
||||
.click();
|
||||
}
|
||||
async function save(page: Page) {
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Opslaan', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
}
|
||||
async function confirmDelete(page: Page) {
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Definitief verwijderen', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test('manages a school, shared memberships and shows through the real REST API', async ({
|
||||
page,
|
||||
}) => {
|
||||
const schoolName = `E2E Studio ${Date.now()}`;
|
||||
let schoolId: number | undefined;
|
||||
const browserErrors: string[] = [];
|
||||
page.on('pageerror', (error) => browserErrors.push(error.message));
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Dansschool toevoegen' }).click();
|
||||
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill(schoolName);
|
||||
const created = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('/api/dance-schools') && response.request().method() === 'POST',
|
||||
);
|
||||
await save(page);
|
||||
schoolId = (await (await created).json()).id;
|
||||
schoolToDelete = schoolId;
|
||||
await page.getByRole('link', { name: new RegExp(schoolName) }).click();
|
||||
await expect(page.getByRole('heading', { name: schoolName, exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'School bewerken' }).click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByLabel('Naam', { exact: true })
|
||||
.fill(`${schoolName} vernieuwd`);
|
||||
await save(page);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `${schoolName} vernieuwd`, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Voeg een danser toe/ }).click();
|
||||
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Sam Testdanser');
|
||||
await page.getByLabel('Geboortedatum').fill('2010-05-12');
|
||||
await save(page);
|
||||
await tab(page, 'Dansers');
|
||||
await expect(page.getByRole('cell', { name: 'Sam Testdanser', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sam Testdanser bewerken' }).click();
|
||||
await page.getByLabel('Geboortedatum').fill('2011-06-13');
|
||||
await save(page);
|
||||
await expect(page.getByRole('cell', { name: '13 jun 2011' })).toBeVisible();
|
||||
|
||||
await tab(page, 'Dansgroepen');
|
||||
for (const name of ['Hiphop Teens', 'Jazz Teens']) {
|
||||
await page.getByRole('button', { name: 'Dansgroep toevoegen' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('Naam / categorie').fill(name);
|
||||
await dialog.getByLabel('Leeftijd vanaf').fill('12');
|
||||
await dialog.getByLabel('Leeftijd tot en met').fill('18');
|
||||
await dialog.getByLabel(name.startsWith('Hiphop') ? 'Hiphop' : 'Jazz', { exact: true }).check();
|
||||
await save(page);
|
||||
await page.getByRole('button', { name: `Dansers beheren voor ${name}`, exact: true }).click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.locator('.member-row')
|
||||
.filter({ hasText: 'Sam Testdanser' })
|
||||
.getByRole('checkbox')
|
||||
.check();
|
||||
await expect(page.getByRole('dialog').getByText('1 van 1 dansers gekoppeld')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Gereed', exact: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('article')
|
||||
.filter({ has: page.getByRole('heading', { name, exact: true }) })
|
||||
.getByText(/1 dansers/),
|
||||
).toBeVisible();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Hiphop Teens bewerken' }).click();
|
||||
await page.getByLabel('Naam / categorie').fill('Hiphop Selectie');
|
||||
await page.getByLabel('Type', { exact: true }).selectOption({ label: 'Selectie' });
|
||||
await save(page);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: 'Hiphop Selectie' })
|
||||
.getByText(/1 dansers/),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true });
|
||||
|
||||
await tab(page, 'Shows');
|
||||
await page.getByRole('button', { name: 'Show toevoegen' }).click();
|
||||
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
|
||||
await page.getByLabel('Datum', { exact: true }).fill('2027-06-12');
|
||||
await save(page);
|
||||
await expect(page.getByText('Ochtend', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Middag', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Avond', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
|
||||
await page.getByLabel('Datum', { exact: true }).fill('2027-07-01');
|
||||
await save(page);
|
||||
await expect(page.locator('.date-tile')).toContainText('jul 2027');
|
||||
|
||||
await tab(page, 'Overzicht');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `${schoolName} vernieuwd`, exact: true }),
|
||||
).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(
|
||||
true,
|
||||
);
|
||||
await page.screenshot({ path: 'test-results/overview-mobile.png', fullPage: true });
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.screenshot({ path: 'test-results/overview-desktop.png', fullPage: true });
|
||||
|
||||
await tab(page, 'Dansers');
|
||||
await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click();
|
||||
await confirmDelete(page);
|
||||
await expect(page.getByText('De dansvloer is nog leeg')).toBeVisible();
|
||||
await tab(page, 'Dansgroepen');
|
||||
await expect(page.getByText(/0 dansers/)).toHaveCount(2);
|
||||
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click();
|
||||
await confirmDelete(page);
|
||||
await expect(page.getByRole('heading', { name: 'Hiphop Selectie', exact: true })).toHaveCount(0);
|
||||
await tab(page, 'Shows');
|
||||
await page.getByRole('button', { name: 'Zomershow verwijderen' }).click();
|
||||
await confirmDelete(page);
|
||||
await expect(page.getByText('Het podium wacht op je')).toBeVisible();
|
||||
await tab(page, 'Overzicht');
|
||||
await page.getByRole('button', { name: 'School verwijderen', exact: true }).click();
|
||||
await confirmDelete(page);
|
||||
await expect(page).toHaveURL(/\/schools$/);
|
||||
await expect(page.getByRole('link', { name: new RegExp(schoolName) })).toHaveCount(0);
|
||||
expect(browserErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('shows a useful connection error and recovers on retry', async ({ page }) => {
|
||||
await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed'));
|
||||
await page.goto('/schools');
|
||||
await expect(page.getByRole('alert')).toContainText('niet bereikbaar');
|
||||
await page.unroute('**/api/dance-schools');
|
||||
await page.getByRole('button', { name: 'Opnieuw proberen' }).click();
|
||||
await expect(page.getByRole('heading', { name: /Mijn dansscholen/ })).toBeVisible();
|
||||
});
|
||||
18
agiledancers-fe/nginx/default.conf.template
Normal file
18
agiledancers-fe/nginx/default.conf.template
Normal file
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://${BACKEND_UPSTREAM};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
7015
agiledancers-fe/package-lock.json
generated
Normal file
7015
agiledancers-fe/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
agiledancers-fe/package.json
Normal file
36
agiledancers-fe/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "agiledancers-fe",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test --watch=false",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "22.2.3",
|
||||
"@angular/compiler": "22.2.3",
|
||||
"@angular/core": "22.2.3",
|
||||
"@angular/forms": "22.2.3",
|
||||
"@angular/platform-browser": "22.2.3",
|
||||
"@angular/router": "22.2.3",
|
||||
"rxjs": "7.8.2",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "22.2.4",
|
||||
"@angular/cli": "22.2.4",
|
||||
"@angular/compiler-cli": "22.2.3",
|
||||
"@playwright/test": "1.64.1",
|
||||
"jsdom": "30.1.3",
|
||||
"prettier": "3.9.10",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
}
|
||||
36
agiledancers-fe/playwright.config.ts
Normal file
36
agiledancers-fe/playwright.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const externalURL = process.env['PLAYWRIGHT_BASE_URL'];
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
reporter: process.env['CI']
|
||||
? [
|
||||
['list'],
|
||||
['junit', { outputFile: 'test-results/playwright.xml' }],
|
||||
['html', { open: 'never' }],
|
||||
]
|
||||
: 'list',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
baseURL: externalURL || 'http://127.0.0.1:4200',
|
||||
trace: 'retain-on-failure',
|
||||
actionTimeout: 15_000,
|
||||
screenshot: 'only-on-failure',
|
||||
launchOptions: process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH']
|
||||
? { executablePath: process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] }
|
||||
: {},
|
||||
},
|
||||
webServer: externalURL
|
||||
? undefined
|
||||
: {
|
||||
command: 'npm start -- --host 127.0.0.1 --port 4200',
|
||||
url: 'http://127.0.0.1:4200',
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
7
agiledancers-fe/proxy.conf.json
Normal file
7
agiledancers-fe/proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"/api/**": {
|
||||
"target": "http://127.0.0.1:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
1
agiledancers-fe/public/favicon.svg
Normal file
1
agiledancers-fe/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="12" fill="#185744"/><path d="M12 27V16a5 5 0 0 1 10 0v11M12 21h10m1-11h7v7m0-7-9 9" stroke="#ddf4b6" stroke-width="3" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 231 B |
17
agiledancers-fe/src/app/app.config.ts
Normal file
17
agiledancers-fe/src/app/app.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localeNl from '@angular/common/locales/nl';
|
||||
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
registerLocaleData(localeNl);
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideHttpClient(),
|
||||
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })),
|
||||
{ provide: LOCALE_ID, useValue: 'nl-NL' },
|
||||
],
|
||||
};
|
||||
239
agiledancers-fe/src/app/app.css
Normal file
239
agiledancers-fe/src/app/app.css
Normal file
@@ -0,0 +1,239 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 250px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 36px 22px 22px;
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--ink);
|
||||
font-size: 21px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.brand-mark {
|
||||
background: var(--forest);
|
||||
color: var(--lime);
|
||||
width: 42px;
|
||||
height: 44px;
|
||||
border-radius: 13px 13px 13px 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
position: relative;
|
||||
}
|
||||
.brand-mark > span {
|
||||
font-size: 23px;
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
top: -5px;
|
||||
}
|
||||
.brand-light {
|
||||
font-weight: 450;
|
||||
}
|
||||
.brand small {
|
||||
font-size: 8px;
|
||||
display: block;
|
||||
letter-spacing: 1.8px;
|
||||
font-weight: 600;
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.nav-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.8px;
|
||||
color: var(--muted);
|
||||
margin: 60px 14px 14px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
padding: 15px 12px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.nav-item.selected {
|
||||
background: #eaf1ec;
|
||||
color: var(--forest);
|
||||
}
|
||||
.nav-item svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.nav-item > span {
|
||||
margin-left: auto;
|
||||
}
|
||||
.sidebar-note {
|
||||
margin-top: auto;
|
||||
padding: 28px 16px;
|
||||
}
|
||||
.sidebar-note > span {
|
||||
color: #569473;
|
||||
font-size: 45px;
|
||||
}
|
||||
.sidebar-note h3 {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 1.25;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.sidebar-note p {
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: var(--muted);
|
||||
}
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 22px 10px 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.sidebar-footer > span:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
.status-dot,
|
||||
.topbar-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #6a9c7b;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.main-column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.topbar {
|
||||
height: 78px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 48px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
main {
|
||||
padding: 44px 48px 60px;
|
||||
max-width: 1500px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
flex: 1;
|
||||
}
|
||||
.page-footer {
|
||||
margin: 0 48px;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 22px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (min-width: 1600px) {
|
||||
main {
|
||||
padding: 58px 68px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 205px minmax(0, 1fr);
|
||||
}
|
||||
.sidebar {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
.brand {
|
||||
font-size: 18px;
|
||||
gap: 8px;
|
||||
}
|
||||
main {
|
||||
padding: 32px 28px 48px;
|
||||
}
|
||||
.topbar {
|
||||
padding: 0 28px;
|
||||
}
|
||||
.page-footer {
|
||||
margin: 0 28px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.app-layout {
|
||||
display: block;
|
||||
}
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
padding: 18px 20px;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-right: 0;
|
||||
gap: 18px;
|
||||
}
|
||||
.brand {
|
||||
padding: 0;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 34px;
|
||||
height: 36px;
|
||||
}
|
||||
.nav-label,
|
||||
.sidebar-note,
|
||||
.sidebar-footer,
|
||||
.nav-item svg,
|
||||
.nav-item > span {
|
||||
display: none;
|
||||
}
|
||||
.nav-item {
|
||||
font-size: 11px;
|
||||
padding: 12px;
|
||||
}
|
||||
.topbar {
|
||||
height: 48px;
|
||||
padding: 0 22px;
|
||||
}
|
||||
.topbar-right {
|
||||
font-size: 10px;
|
||||
}
|
||||
main {
|
||||
padding: 28px 20px 42px;
|
||||
}
|
||||
.page-footer {
|
||||
margin: 0 20px;
|
||||
}
|
||||
}
|
||||
46
agiledancers-fe/src/app/app.html
Normal file
46
agiledancers-fe/src/app/app.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<a class="skip-link" href="#main-content">Naar inhoud</a>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<a routerLink="/schools" class="brand" aria-label="Agile Dancers – alle dansscholen"
|
||||
><span class="brand-mark" aria-hidden="true">a<span>↗</span></span
|
||||
><span
|
||||
>agile<span class="brand-light">dancers</span><small>IN BEWEGING, SAMEN.</small></span
|
||||
></a
|
||||
>
|
||||
<p class="nav-label">WERKRUIMTE</p>
|
||||
<nav aria-label="Hoofdnavigatie">
|
||||
<a class="nav-item selected" routerLink="/schools"
|
||||
><svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m3 10 9-7 9 7v10H3Z" />
|
||||
<path d="M9 20v-7h6v7" /></svg
|
||||
>Mijn dansscholen<span aria-hidden="true">→</span></a
|
||||
>
|
||||
</nav>
|
||||
<div class="sidebar-note">
|
||||
<span aria-hidden="true">✳</span>
|
||||
<h3>Alles begint<br />met beweging.</h3>
|
||||
<p>Jij zorgt voor de dans.<br />Wij houden het overzicht.</p>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<span class="status-dot" aria-hidden="true"></span> Agile Dancers <span>1.0</span>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="main-column">
|
||||
<header class="topbar">
|
||||
<span>Dansschoolbeheer</span
|
||||
><span class="topbar-right"
|
||||
><span class="topbar-dot" aria-hidden="true"></span> Studio workspace</span
|
||||
>
|
||||
</header>
|
||||
<main id="main-content" tabindex="-1"><router-outlet /></main>
|
||||
<footer class="page-footer">
|
||||
<span>Gemaakt voor de mensen achter de dans.</span><span>Agile Dancers</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
21
agiledancers-fe/src/app/app.routes.ts
Normal file
21
agiledancers-fe/src/app/app.routes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'schools' },
|
||||
{
|
||||
path: 'schools',
|
||||
title: 'Mijn dansscholen · Agile Dancers',
|
||||
loadComponent: () => import('./schools/schools-page').then((m) => m.SchoolsPage),
|
||||
},
|
||||
{ path: 'schools/:schoolId', pathMatch: 'full', redirectTo: 'schools/:schoolId/overview' },
|
||||
{
|
||||
path: 'schools/:schoolId/:section',
|
||||
title: 'Dansschool beheren · Agile Dancers',
|
||||
loadComponent: () => import('./schools/school-page').then((m) => m.SchoolPage),
|
||||
canMatch: [
|
||||
(_route, segments) =>
|
||||
['overview', 'dancers', 'groups', 'shows'].includes(segments[2]?.path ?? ''),
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: 'schools' },
|
||||
];
|
||||
19
agiledancers-fe/src/app/app.spec.ts
Normal file
19
agiledancers-fe/src/app/app.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { App } from './app';
|
||||
|
||||
describe('Application shell', () => {
|
||||
it('provides navigation and a keyboard skip link', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const page = fixture.nativeElement as HTMLElement;
|
||||
expect(page.querySelector('nav[aria-label="Hoofdnavigatie"] a')?.getAttribute('href')).toBe(
|
||||
'/schools',
|
||||
);
|
||||
expect(page.querySelector('.skip-link')?.getAttribute('href')).toBe('#main-content');
|
||||
});
|
||||
});
|
||||
10
agiledancers-fe/src/app/app.ts
Normal file
10
agiledancers-fe/src/app/app.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, RouterLink],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App {}
|
||||
24
agiledancers-fe/src/app/core/api-error.ts
Normal file
24
agiledancers-fe/src/app/core/api-error.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
export function apiError(error: unknown): string {
|
||||
if (!(error instanceof HttpErrorResponse)) return 'Er ging iets mis. Probeer het opnieuw.';
|
||||
if (error.status === 0 || error.status === 502 || error.status === 504) {
|
||||
return 'De server is niet bereikbaar. Controleer of de backend draait en probeer het opnieuw.';
|
||||
}
|
||||
if (error.status === 404) return 'Deze gegevens zijn niet meer beschikbaar. Vernieuw de pagina.';
|
||||
const body: unknown = error.error;
|
||||
if (body && typeof body === 'object') {
|
||||
if ('message' in body && typeof body.message === 'string') return body.message;
|
||||
if ('violations' in body && Array.isArray(body.violations)) {
|
||||
const messages = body.violations
|
||||
.map((v: unknown) =>
|
||||
v && typeof v === 'object' && 'message' in v ? String(v.message) : '',
|
||||
)
|
||||
.filter(Boolean);
|
||||
if (messages.length) return messages.join(' · ');
|
||||
}
|
||||
}
|
||||
return error.status === 400
|
||||
? 'Controleer de ingevulde gegevens en probeer het opnieuw.'
|
||||
: 'Opslaan is niet gelukt. Probeer het opnieuw.';
|
||||
}
|
||||
74
agiledancers-fe/src/app/core/api-models.ts
Normal file
74
agiledancers-fe/src/app/core/api-models.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export interface School {
|
||||
id: number;
|
||||
name: string;
|
||||
dancerIds: number[];
|
||||
danceGroupIds: number[];
|
||||
showIds: number[];
|
||||
}
|
||||
export interface Dancer {
|
||||
id: number;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
}
|
||||
export type GroupType = 'SHOW_CASE' | 'PRE_EDUCATION' | 'SELECTION' | 'REGULAR' | 'PRE_SCHOOLER';
|
||||
export type GroupStyle = 'HIPHOP' | 'JAZZ' | 'MODERN' | 'STREETDANCE' | 'CLASSIC' | 'BREAKDANCE';
|
||||
export interface DanceGroup {
|
||||
id: number;
|
||||
category: string;
|
||||
type: GroupType;
|
||||
startAge: number;
|
||||
endAge: number;
|
||||
styles: GroupStyle[];
|
||||
dancerIds: number[];
|
||||
}
|
||||
export interface Show {
|
||||
id: number;
|
||||
name: string;
|
||||
date: string;
|
||||
dayParts: { type: 'MORNING' | 'AFTERNOON' | 'EVENING'; danceGroupIds: number[] }[];
|
||||
}
|
||||
export interface SchoolRequest {
|
||||
name: string;
|
||||
}
|
||||
export interface DancerRequest {
|
||||
name: string;
|
||||
birthDate: string;
|
||||
}
|
||||
export interface GroupRequest {
|
||||
category: string;
|
||||
type: GroupType;
|
||||
startAge: number;
|
||||
endAge: number;
|
||||
styles: GroupStyle[];
|
||||
}
|
||||
export interface ShowRequest {
|
||||
name: string;
|
||||
date: string;
|
||||
}
|
||||
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
|
||||
export type EditorTarget =
|
||||
| { kind: 'school'; value?: School }
|
||||
| { kind: 'dancer'; value?: Dancer }
|
||||
| { kind: 'group'; value?: DanceGroup }
|
||||
| { kind: 'show'; value?: Show };
|
||||
export interface SchoolData {
|
||||
school: School;
|
||||
dancers: Dancer[];
|
||||
groups: DanceGroup[];
|
||||
shows: Show[];
|
||||
}
|
||||
export const GROUP_TYPES: { value: GroupType; label: string }[] = [
|
||||
{ value: 'REGULAR', label: 'Regulier' },
|
||||
{ value: 'SHOW_CASE', label: 'Showcase' },
|
||||
{ value: 'PRE_EDUCATION', label: 'Vooropleiding' },
|
||||
{ value: 'SELECTION', label: 'Selectie' },
|
||||
{ value: 'PRE_SCHOOLER', label: 'Kleuters' },
|
||||
];
|
||||
export const GROUP_STYLES: { value: GroupStyle; label: string }[] = [
|
||||
{ value: 'HIPHOP', label: 'Hiphop' },
|
||||
{ value: 'JAZZ', label: 'Jazz' },
|
||||
{ value: 'MODERN', label: 'Modern' },
|
||||
{ value: 'STREETDANCE', label: 'Streetdance' },
|
||||
{ value: 'CLASSIC', label: 'Klassiek' },
|
||||
{ value: 'BREAKDANCE', label: 'Breakdance' },
|
||||
];
|
||||
69
agiledancers-fe/src/app/core/school-api.spec.ts
Normal file
69
agiledancers-fe/src/app/core/school-api.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SchoolApi } from './school-api';
|
||||
|
||||
describe('SchoolApi contract', () => {
|
||||
let api: SchoolApi;
|
||||
let http: HttpTestingController;
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
api = TestBed.inject(SchoolApi);
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
|
||||
it('loads all resources from the same school', () => {
|
||||
const received = vi.fn();
|
||||
api.schoolData(7).subscribe(received);
|
||||
const school = { id: 7, name: 'Studio', dancerIds: [], danceGroupIds: [], showIds: [] };
|
||||
http.expectOne('/api/dance-schools/7').flush(school);
|
||||
http.expectOne('/api/dance-schools/7/dancers').flush([]);
|
||||
http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
|
||||
expect(received).not.toHaveBeenCalled();
|
||||
http.expectOne('/api/dance-schools/7/shows').flush([]);
|
||||
expect(received).toHaveBeenCalledWith({ school, dancers: [], groups: [], shows: [] });
|
||||
});
|
||||
|
||||
it('updates an existing entity even when its id is zero', () => {
|
||||
api.saveSchool({ name: 'Renamed' }, 0).subscribe();
|
||||
const request = http.expectOne('/api/dance-schools/0');
|
||||
expect(request.request.method).toBe('PUT');
|
||||
expect(request.request.body).toEqual({ name: 'Renamed' });
|
||||
request.flush({ id: 0, name: 'Renamed', dancerIds: [], danceGroupIds: [], showIds: [] });
|
||||
});
|
||||
|
||||
it('keeps group details separate from memberships', () => {
|
||||
api
|
||||
.saveGroup(
|
||||
7,
|
||||
{ category: 'Teens', type: 'REGULAR', startAge: 12, endAge: 18, styles: ['HIPHOP'] },
|
||||
0,
|
||||
)
|
||||
.subscribe();
|
||||
const request = http.expectOne('/api/dance-schools/7/dance-groups/0');
|
||||
expect(request.request.method).toBe('PUT');
|
||||
expect(request.request.body).not.toHaveProperty('dancerIds');
|
||||
request.flush({});
|
||||
api.membership(7, 0, 3, true).subscribe();
|
||||
const add = http.expectOne('/api/dance-schools/7/dance-groups/0/dancers/3');
|
||||
expect(add.request.method).toBe('PUT');
|
||||
expect(add.request.body).toBeNull();
|
||||
add.flush(null);
|
||||
api.membership(7, 0, 3, false).subscribe();
|
||||
const remove = http.expectOne('/api/dance-schools/7/dance-groups/0/dancers/3');
|
||||
expect(remove.request.method).toBe('DELETE');
|
||||
remove.flush(null);
|
||||
});
|
||||
|
||||
it('propagates server errors instead of pretending a save succeeded', () => {
|
||||
const failure = vi.fn();
|
||||
api.saveDancer(7, { name: 'Sam', birthDate: '2010-05-12' }).subscribe({ error: failure });
|
||||
http
|
||||
.expectOne('/api/dance-schools/7/dancers')
|
||||
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
|
||||
expect(failure).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
63
agiledancers-fe/src/app/core/school-api.ts
Normal file
63
agiledancers-fe/src/app/core/school-api.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { forkJoin, Observable } from 'rxjs';
|
||||
import {
|
||||
DanceGroup,
|
||||
Dancer,
|
||||
DancerRequest,
|
||||
GroupRequest,
|
||||
ResourceKind,
|
||||
School,
|
||||
SchoolData,
|
||||
SchoolRequest,
|
||||
Show,
|
||||
ShowRequest,
|
||||
} from './api-models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SchoolApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = '/api/dance-schools';
|
||||
|
||||
schools(): Observable<School[]> {
|
||||
return this.http.get<School[]>(this.base);
|
||||
}
|
||||
schoolData(schoolId: number): Observable<SchoolData> {
|
||||
const base = `${this.base}/${schoolId}`;
|
||||
return forkJoin({
|
||||
school: this.http.get<School>(base),
|
||||
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
|
||||
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
|
||||
shows: this.http.get<Show[]>(`${base}/shows`),
|
||||
});
|
||||
}
|
||||
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
|
||||
return id === undefined
|
||||
? this.http.post<School>(this.base, body)
|
||||
: this.http.put<School>(`${this.base}/${id}`, body);
|
||||
}
|
||||
saveDancer(schoolId: number, body: DancerRequest, id?: number): Observable<Dancer> {
|
||||
return this.save(`${this.base}/${schoolId}/dancers`, body, id);
|
||||
}
|
||||
saveGroup(schoolId: number, body: GroupRequest, id?: number): Observable<DanceGroup> {
|
||||
return this.save(`${this.base}/${schoolId}/dance-groups`, body, id);
|
||||
}
|
||||
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
|
||||
return this.save(`${this.base}/${schoolId}/shows`, body, id);
|
||||
}
|
||||
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
|
||||
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
|
||||
kind
|
||||
];
|
||||
const url =
|
||||
kind === 'school' ? `${this.base}/${id}` : `${this.base}/${schoolId}${collection}/${id}`;
|
||||
return this.http.delete<void>(url);
|
||||
}
|
||||
membership(schoolId: number, groupId: number, dancerId: number, add: boolean): Observable<void> {
|
||||
const url = `${this.base}/${schoolId}/dance-groups/${groupId}/dancers/${dancerId}`;
|
||||
return add ? this.http.put<void>(url, null) : this.http.delete<void>(url);
|
||||
}
|
||||
private save<T>(url: string, body: unknown, id?: number): Observable<T> {
|
||||
return id === undefined ? this.http.post<T>(url, body) : this.http.put<T>(`${url}/${id}`, body);
|
||||
}
|
||||
}
|
||||
424
agiledancers-fe/src/app/schools/school-page.html
Normal file
424
agiledancers-fe/src/app/schools/school-page.html
Normal file
@@ -0,0 +1,424 @@
|
||||
<a routerLink="/schools" class="back-link">← Alle dansscholen</a>
|
||||
@if (loading()) {
|
||||
<div class="loading-state" role="status"><span class="spinner"></span> Dansschool laden…</div>
|
||||
} @else if (!data()) {
|
||||
<div class="empty-state">
|
||||
<h1>Dansschool niet beschikbaar</h1>
|
||||
<p role="alert">{{ error() }}</p>
|
||||
<button type="button" class="button secondary" (click)="retry()">Opnieuw proberen</button>
|
||||
</div>
|
||||
} @else if (data(); as context) {
|
||||
<div class="page-heading school-heading">
|
||||
<div class="school-title">
|
||||
<span aria-hidden="true" class="avatar large">{{
|
||||
context.school.name.charAt(0).toUpperCase()
|
||||
}}</span>
|
||||
<div>
|
||||
<p class="eyebrow">MIJN DANSSCHOOL</p>
|
||||
<h1>{{ context.school.name }}</h1>
|
||||
<p class="lede">Jouw dansers. Jouw ritme. Jouw podium.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button secondary"
|
||||
(click)="editor.set({ kind: 'school', value: context.school })"
|
||||
>
|
||||
School bewerken
|
||||
</button>
|
||||
</div>
|
||||
<app-school-transfer [schoolId]="context.school.id" (imported)="reload()" />
|
||||
<nav class="tabs" aria-label="Dansschool onderdelen">
|
||||
@for (tab of tabs; track tab.value) {
|
||||
<a
|
||||
[routerLink]="['/schools', context.school.id, tab.value]"
|
||||
routerLinkActive="active"
|
||||
ariaCurrentWhenActive="page"
|
||||
>{{ tab.label }}
|
||||
@switch (tab.value) {
|
||||
@case ('dancers') {
|
||||
<span>{{ context.dancers.length }}</span>
|
||||
}
|
||||
@case ('groups') {
|
||||
<span>{{ context.groups.length }}</span>
|
||||
}
|
||||
@case ('shows') {
|
||||
<span>{{ context.shows.length }}</span>
|
||||
}
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
@if (notice()) {
|
||||
<div class="alert success" role="status">
|
||||
{{ notice()
|
||||
}}<button type="button" aria-label="Melding sluiten" (click)="notice.set('')">×</button>
|
||||
</div>
|
||||
}
|
||||
@if (error()) {
|
||||
<div class="alert error" role="alert">
|
||||
{{ error()
|
||||
}}<button type="button" class="button secondary" (click)="reload()">Vernieuwen</button>
|
||||
</div>
|
||||
}
|
||||
@switch (section()) {
|
||||
@case ('overview') {
|
||||
<div class="stats-grid">
|
||||
<a class="stat" [routerLink]="['/schools', context.school.id, 'dancers']"
|
||||
><span class="stat-label">Dansers <span aria-hidden="true">↗</span></span
|
||||
><strong>{{ context.dancers.length }}</strong
|
||||
><span class="stat-caption">Alle talenten bij elkaar</span></a
|
||||
>
|
||||
<a class="stat" [routerLink]="['/schools', context.school.id, 'groups']"
|
||||
><span class="stat-label">Dansgroepen <span aria-hidden="true">↗</span></span
|
||||
><strong>{{ context.groups.length }}</strong
|
||||
><span class="stat-caption">Samen de vloer op</span></a
|
||||
>
|
||||
<a class="stat" [routerLink]="['/schools', context.school.id, 'shows']"
|
||||
><span class="stat-label">Shows <span aria-hidden="true">↗</span></span
|
||||
><strong>{{ context.shows.length }}</strong
|
||||
><span class="stat-caption">Momenten om naar uit te kijken</span></a
|
||||
>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<section class="panel">
|
||||
<p class="eyebrow">DE VOLGENDE STAP</p>
|
||||
<h2>Breng je school in beweging</h2>
|
||||
<p class="muted">Een goede show begint in de studio. Zorg dat iedereen een plek heeft.</p>
|
||||
<div class="quick-actions">
|
||||
<button type="button" (click)="editor.set({ kind: 'dancer' })">
|
||||
<span class="step-number">01</span
|
||||
><span
|
||||
><strong>Voeg een danser toe</strong
|
||||
><small>Maak ruimte voor nieuw talent</small></span
|
||||
><span aria-hidden="true">+</span>
|
||||
</button>
|
||||
<button type="button" (click)="editor.set({ kind: 'group' })">
|
||||
<span class="step-number">02</span
|
||||
><span
|
||||
><strong>Stel een dansgroep samen</strong
|
||||
><small>Verbind dansers met dezelfde passie</small></span
|
||||
><span aria-hidden="true">+</span>
|
||||
</button>
|
||||
<button type="button" (click)="editor.set({ kind: 'show' })">
|
||||
<span class="step-number">03</span
|
||||
><span
|
||||
><strong>Plan een show</strong
|
||||
><small>Zet het volgende podium op de kalender</small></span
|
||||
><span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="show-spotlight">
|
||||
<div class="spotlight-decoration" aria-hidden="true">✳</div>
|
||||
<p class="eyebrow">IN DE SPOTLIGHT</p>
|
||||
@if (upcoming(); as show) {
|
||||
<h2>{{ show.name }}</h2>
|
||||
<p class="spotlight-date">{{ show.date | date: 'd MMMM yyyy' }}</p>
|
||||
<p>Drie dagdelen. Eén gedeelde passie.</p>
|
||||
<a class="button light" [routerLink]="['/schools', context.school.id, 'shows']"
|
||||
>Shows bekijken <span aria-hidden="true">→</span></a
|
||||
>
|
||||
} @else {
|
||||
<h2>Klaar voor<br />het grote podium?</h2>
|
||||
<p>Maak een show aan en geef je dansers iets om naartoe te werken.</p>
|
||||
<button type="button" class="button light" (click)="editor.set({ kind: 'show' })">
|
||||
Eerste show plannen <span aria-hidden="true">→</span>
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
<section class="danger-zone">
|
||||
<div>
|
||||
<h3>Dansschool verwijderen</h3>
|
||||
<p>Verwijdert ook alle dansers, groepen en shows van deze school.</p>
|
||||
</div>
|
||||
<button
|
||||
class="button danger-outline"
|
||||
type="button"
|
||||
(click)="
|
||||
askDelete({
|
||||
kind: 'school',
|
||||
id: context.school.id,
|
||||
name: context.school.name,
|
||||
explanation:
|
||||
'Alle dansers, dansgroepen en shows van deze school worden ook definitief verwijderd.',
|
||||
})
|
||||
"
|
||||
>
|
||||
School verwijderen
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
@case ('dancers') {
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Dansers</h2>
|
||||
<p class="muted">De mensen die jouw school in beweging brengen.</p>
|
||||
</div>
|
||||
<button type="button" class="button primary" (click)="editor.set({ kind: 'dancer' })">
|
||||
+ Danser toevoegen
|
||||
</button>
|
||||
</div>
|
||||
@if (context.dancers.length) {
|
||||
<label class="search-field"
|
||||
><span aria-hidden="true">⌕</span
|
||||
><input
|
||||
type="search"
|
||||
aria-label="Zoek een danser"
|
||||
placeholder="Zoek op naam…"
|
||||
[value]="search()"
|
||||
(input)="search.set($any($event.target).value)"
|
||||
/></label>
|
||||
}
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<caption class="sr-only">
|
||||
Dansers in
|
||||
{{
|
||||
context.school.name
|
||||
}}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Danser</th>
|
||||
<th>Geboortedatum</th>
|
||||
<th>Dansgroepen</th>
|
||||
<th><span class="sr-only">Acties</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (dancer of dancers(); track dancer.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="person">
|
||||
<span aria-hidden="true" class="avatar small">{{
|
||||
dancer.name.charAt(0).toUpperCase()
|
||||
}}</span
|
||||
><strong>{{ dancer.name }}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nowrap">{{ dancer.birthDate | date: 'd MMM yyyy' }}</td>
|
||||
<td class="muted">{{ groupNames(dancer.id) }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
class="text-button"
|
||||
type="button"
|
||||
[attr.aria-label]="dancer.name + ' bewerken'"
|
||||
(click)="editor.set({ kind: 'dancer', value: dancer })"
|
||||
>
|
||||
Bewerken</button
|
||||
><button
|
||||
class="text-button destructive"
|
||||
type="button"
|
||||
[attr.aria-label]="dancer.name + ' verwijderen'"
|
||||
(click)="
|
||||
askDelete({
|
||||
kind: 'dancer',
|
||||
id: dancer.id,
|
||||
name: dancer.name,
|
||||
explanation:
|
||||
'De danser wordt ook uit alle dansgroepen van deze school verwijderd.',
|
||||
})
|
||||
"
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="empty-state">
|
||||
<h3>{{ search() ? 'Geen dansers gevonden' : 'De dansvloer is nog leeg' }}</h3>
|
||||
<p>
|
||||
{{
|
||||
search()
|
||||
? 'Probeer een andere naam.'
|
||||
: 'Voeg je eerste danser toe. Daarna kun je die aan één of meerdere groepen koppelen.'
|
||||
}}
|
||||
</p>
|
||||
@if (!search()) {
|
||||
<button
|
||||
class="button primary"
|
||||
type="button"
|
||||
(click)="editor.set({ kind: 'dancer' })"
|
||||
>
|
||||
Danser toevoegen
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@case ('groups') {
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Dansgroepen</h2>
|
||||
<p class="muted">Iedere groep een eigen stijl. Iedere danser een plek.</p>
|
||||
</div>
|
||||
<button class="button primary" type="button" (click)="editor.set({ kind: 'group' })">
|
||||
+ Dansgroep toevoegen
|
||||
</button>
|
||||
</div>
|
||||
<div class="school-grid">
|
||||
@for (group of context.groups; track group.id) {
|
||||
<article class="group-card">
|
||||
<div class="card-top">
|
||||
<span class="group-symbol" aria-hidden="true">↗</span
|
||||
><span class="tag">{{ groupType(group.type) }}</span>
|
||||
</div>
|
||||
<h3>{{ group.category }}</h3>
|
||||
<p class="muted">
|
||||
{{ group.startAge }}–{{ group.endAge }} jaar · {{ group.dancerIds.length }} dansers
|
||||
</p>
|
||||
<div class="tag-list">
|
||||
@for (style of group.styles; track style) {
|
||||
<span class="style-tag">{{ groupStyle(style) }}</span>
|
||||
} @empty {
|
||||
<span class="muted">Nog geen dansstijlen</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="button secondary full-width"
|
||||
type="button"
|
||||
[attr.aria-label]="'Dansers beheren voor ' + group.category"
|
||||
(click)="members.set(group)"
|
||||
>
|
||||
Dansers beheren <span aria-hidden="true">→</span>
|
||||
</button>
|
||||
<div class="card-bottom">
|
||||
<button
|
||||
class="text-button"
|
||||
type="button"
|
||||
[attr.aria-label]="group.category + ' bewerken'"
|
||||
(click)="editor.set({ kind: 'group', value: group })"
|
||||
>
|
||||
Bewerken</button
|
||||
><button
|
||||
class="text-button destructive"
|
||||
type="button"
|
||||
[attr.aria-label]="group.category + ' verwijderen'"
|
||||
(click)="
|
||||
askDelete({
|
||||
kind: 'group',
|
||||
id: group.id,
|
||||
name: group.category,
|
||||
explanation:
|
||||
'De groep wordt verwijderd. De dansers blijven beschikbaar in de school.',
|
||||
})
|
||||
"
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
} @empty {
|
||||
<div class="empty-state full-width">
|
||||
<span class="empty-symbol" aria-hidden="true">↗</span>
|
||||
<h2>Samen vind je het ritme</h2>
|
||||
<p>Maak een dansgroep aan en koppel er dansers aan.</p>
|
||||
<button class="button primary" type="button" (click)="editor.set({ kind: 'group' })">
|
||||
Eerste dansgroep aanmaken
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('shows') {
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Shows</h2>
|
||||
<p class="muted">Van maanden oefenen naar een onvergetelijk optreden.</p>
|
||||
</div>
|
||||
<button class="button primary" type="button" (click)="editor.set({ kind: 'show' })">
|
||||
+ Show toevoegen
|
||||
</button>
|
||||
</div>
|
||||
<div class="show-list">
|
||||
@for (show of shows(); track show.id) {
|
||||
<article class="show-card">
|
||||
<div class="date-tile">
|
||||
<strong>{{ show.date | date: 'dd' }}</strong
|
||||
><span>{{ show.date | date: 'MMM yyyy' }}</span>
|
||||
</div>
|
||||
<div class="show-details">
|
||||
<h3>{{ show.name }}</h3>
|
||||
<div class="tag-list">
|
||||
<span class="style-tag">Ochtend</span><span class="style-tag">Middag</span
|
||||
><span class="style-tag">Avond</span>
|
||||
</div>
|
||||
<p class="muted">Groepen indelen over dagdelen is nog niet beschikbaar.</p>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
class="text-button"
|
||||
type="button"
|
||||
[attr.aria-label]="show.name + ' bewerken'"
|
||||
(click)="editor.set({ kind: 'show', value: show })"
|
||||
>
|
||||
Bewerken</button
|
||||
><button
|
||||
class="text-button destructive"
|
||||
type="button"
|
||||
[attr.aria-label]="show.name + ' verwijderen'"
|
||||
(click)="
|
||||
askDelete({
|
||||
kind: 'show',
|
||||
id: show.id,
|
||||
name: show.name,
|
||||
explanation: 'Deze show wordt definitief verwijderd.',
|
||||
})
|
||||
"
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
} @empty {
|
||||
<div class="empty-state">
|
||||
<span class="empty-symbol" aria-hidden="true">✦</span>
|
||||
<h2>Het podium wacht op je</h2>
|
||||
<p>Plan een show met een ochtend-, middag- en avonddeel.</p>
|
||||
<button class="button primary" type="button" (click)="editor.set({ kind: 'show' })">
|
||||
Eerste show plannen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (editor(); as target) {
|
||||
<app-entity-editor
|
||||
[target]="target"
|
||||
[schoolId]="context.school.id"
|
||||
(closed)="editor.set(null)"
|
||||
(saved)="saved()"
|
||||
/>
|
||||
}
|
||||
@if (members(); as group) {
|
||||
<app-group-members
|
||||
[group]="group"
|
||||
[schoolId]="context.school.id"
|
||||
[dancers]="context.dancers"
|
||||
(closed)="members.set(null)"
|
||||
(changed)="membershipChanged($event)"
|
||||
/>
|
||||
}
|
||||
@if (deleting(); as target) {
|
||||
<app-confirm-dialog
|
||||
[name]="target.name"
|
||||
[explanation]="target.explanation"
|
||||
[busy]="deleteBusy()"
|
||||
[error]="deleteError()"
|
||||
(closed)="deleting.set(null)"
|
||||
(confirmed)="confirmDelete()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
171
agiledancers-fe/src/app/schools/school-page.ts
Normal file
171
agiledancers-fe/src/app/schools/school-page.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { SchoolTransfer } from '../shared/school-transfer';
|
||||
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { catchError, EMPTY, firstValueFrom, switchMap } from 'rxjs';
|
||||
import {
|
||||
DanceGroup,
|
||||
EditorTarget,
|
||||
GROUP_STYLES,
|
||||
GROUP_TYPES,
|
||||
ResourceKind,
|
||||
SchoolData,
|
||||
} from '../core/api-models';
|
||||
import { apiError } from '../core/api-error';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { EntityEditor } from '../shared/entity-editor';
|
||||
import { ConfirmDialog } from '../shared/confirm-dialog';
|
||||
import { GroupMembers } from '../shared/group-members';
|
||||
|
||||
interface DeleteTarget {
|
||||
kind: ResourceKind;
|
||||
id: number;
|
||||
name: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-school-page',
|
||||
imports: [
|
||||
SchoolTransfer,
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
EntityEditor,
|
||||
ConfirmDialog,
|
||||
GroupMembers,
|
||||
],
|
||||
templateUrl: './school-page.html',
|
||||
})
|
||||
export class SchoolPage implements OnInit {
|
||||
private readonly api = inject(SchoolApi);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
readonly data = signal<SchoolData | null>(null);
|
||||
readonly section = signal('overview');
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal('');
|
||||
readonly notice = signal('');
|
||||
readonly editor = signal<EditorTarget | null>(null);
|
||||
readonly members = signal<DanceGroup | null>(null);
|
||||
readonly deleting = signal<DeleteTarget | null>(null);
|
||||
readonly deleteBusy = signal(false);
|
||||
readonly deleteError = signal('');
|
||||
readonly search = signal('');
|
||||
readonly dancers = computed(() =>
|
||||
[...(this.data()?.dancers ?? [])]
|
||||
.filter((d) => d.name.toLocaleLowerCase('nl').includes(this.search().toLocaleLowerCase('nl')))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'nl')),
|
||||
);
|
||||
readonly shows = computed(() =>
|
||||
[...(this.data()?.shows ?? [])].sort((a, b) => a.date.localeCompare(b.date)),
|
||||
);
|
||||
readonly today = new Date().toLocaleDateString('en-CA');
|
||||
readonly upcoming = computed(() => this.shows().find((show) => show.date >= this.today));
|
||||
readonly tabs = [
|
||||
{ value: 'overview', label: 'Overzicht' },
|
||||
{ value: 'dancers', label: 'Dansers' },
|
||||
{ value: 'groups', label: 'Dansgroepen' },
|
||||
{ value: 'shows', label: 'Shows' },
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
switchMap((params) => {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.search.set('');
|
||||
this.editor.set(null);
|
||||
this.members.set(null);
|
||||
this.deleting.set(null);
|
||||
this.section.set(params.get('section') ?? 'overview');
|
||||
return this.api.schoolData(Number(params.get('schoolId'))).pipe(
|
||||
catchError((error) => {
|
||||
this.data.set(null);
|
||||
this.error.set(apiError(error));
|
||||
this.loading.set(false);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((data) => {
|
||||
this.data.set(data);
|
||||
this.loading.set(false);
|
||||
});
|
||||
}
|
||||
async reload(): Promise<void> {
|
||||
const id = this.data()?.school.id;
|
||||
if (id === undefined) return;
|
||||
this.error.set('');
|
||||
try {
|
||||
const data = await firstValueFrom(this.api.schoolData(id));
|
||||
if (this.data()?.school.id === id) this.data.set(data);
|
||||
} catch (error: unknown) {
|
||||
this.error.set(apiError(error));
|
||||
}
|
||||
}
|
||||
retry(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
async saved(): Promise<void> {
|
||||
this.editor.set(null);
|
||||
this.notice.set('Wijzigingen opgeslagen.');
|
||||
await this.reload();
|
||||
}
|
||||
groupType(value: string): string {
|
||||
return GROUP_TYPES.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
groupStyle(value: string): string {
|
||||
return GROUP_STYLES.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
groupNames(dancerId: number): string {
|
||||
return (
|
||||
this.data()
|
||||
?.groups.filter((group) => group.dancerIds.includes(dancerId))
|
||||
.map((group) => group.category)
|
||||
.join(', ') || 'Nog geen groep'
|
||||
);
|
||||
}
|
||||
askDelete(target: DeleteTarget): void {
|
||||
this.deleteError.set('');
|
||||
this.deleting.set(target);
|
||||
}
|
||||
membershipChanged(ids: number[]): void {
|
||||
const groupId = this.members()?.id;
|
||||
this.data.update((data) =>
|
||||
data
|
||||
? {
|
||||
...data,
|
||||
groups: data.groups.map((group) =>
|
||||
group.id === groupId ? { ...group, dancerIds: [...ids] } : group,
|
||||
),
|
||||
}
|
||||
: data,
|
||||
);
|
||||
}
|
||||
async confirmDelete(): Promise<void> {
|
||||
const target = this.deleting();
|
||||
const schoolId = this.data()?.school.id;
|
||||
if (!target || schoolId === undefined || this.deleteBusy()) return;
|
||||
this.deleteBusy.set(true);
|
||||
this.deleteError.set('');
|
||||
try {
|
||||
await firstValueFrom(this.api.delete(target.kind, schoolId, target.id));
|
||||
this.deleting.set(null);
|
||||
if (target.kind === 'school') await this.router.navigate(['/schools']);
|
||||
else {
|
||||
this.notice.set(`${target.name} is verwijderd.`);
|
||||
await this.reload();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.deleteError.set(apiError(error));
|
||||
} finally {
|
||||
this.deleteBusy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
94
agiledancers-fe/src/app/schools/schools-page.html
Normal file
94
agiledancers-fe/src/app/schools/schools-page.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<app-school-transfer (imported)="load()" />
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">JOUW ORGANISATIE</p>
|
||||
<h1>Alle ruimte om te dansen.</h1>
|
||||
<p class="lede">Van de eerste les tot het grote podium. Beheer je dansschool op één plek.</p>
|
||||
</div>
|
||||
<button type="button" class="button primary" (click)="editor.set(true)">
|
||||
<span aria-hidden="true">+</span> Dansschool toevoegen
|
||||
</button>
|
||||
</div>
|
||||
@if (notice()) {
|
||||
<div class="alert success" role="status">
|
||||
{{ notice()
|
||||
}}<button type="button" aria-label="Melding sluiten" (click)="notice.set('')">×</button>
|
||||
</div>
|
||||
}
|
||||
@if (loading()) {
|
||||
<div class="loading-state" role="status"><span class="spinner"></span> Dansscholen laden…</div>
|
||||
} @else if (error()) {
|
||||
<div class="alert error" role="alert">
|
||||
<p>{{ error() }}</p>
|
||||
<button class="button secondary" type="button" (click)="load()">Opnieuw proberen</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Dansscholen</span><strong>{{ schools().length }}</strong
|
||||
><span class="stat-caption">Een plek voor iedere danser</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Dansers</span><strong>{{ totalDancers() }}</strong
|
||||
><span class="stat-caption">Samen in beweging</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Dansgroepen</span><strong>{{ totalGroups() }}</strong
|
||||
><span class="stat-caption">Ieder met een eigen ritme</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-heading">
|
||||
<h2>
|
||||
Mijn dansscholen <span class="count-badge">{{ schools().length }}</span>
|
||||
</h2>
|
||||
<span class="muted">Selecteer een school om aan de slag te gaan</span>
|
||||
</div>
|
||||
<div class="school-grid">
|
||||
@for (school of schools(); track school.id) {
|
||||
<a class="school-card" [routerLink]="['/schools', school.id, 'overview']">
|
||||
<div class="card-top">
|
||||
<span aria-hidden="true" class="avatar">{{ school.name.charAt(0).toUpperCase() }}</span
|
||||
><span class="round-arrow" aria-hidden="true">↗</span>
|
||||
</div>
|
||||
<h3>{{ school.name }}</h3>
|
||||
<p>Dansschool</p>
|
||||
<div class="card-meta">
|
||||
<span
|
||||
><strong>{{ school.dancerIds.length }}</strong> dansers</span
|
||||
><span
|
||||
><strong>{{ school.danceGroupIds.length }}</strong> groepen</span
|
||||
><span
|
||||
><strong>{{ school.showIds.length }}</strong> shows</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-link">Dansschool beheren <span aria-hidden="true">→</span></div>
|
||||
</a>
|
||||
} @empty {
|
||||
<div class="empty-state full-width">
|
||||
<span class="empty-symbol" aria-hidden="true">✳</span>
|
||||
<h2>Hier begint jouw dansschool</h2>
|
||||
<p>Maak een school aan en geef je dansers, groepen en shows een thuis.</p>
|
||||
<button class="button primary" type="button" (click)="editor.set(true)">
|
||||
Eerste dansschool aanmaken
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<aside class="tip-panel">
|
||||
<span class="tip-icon" aria-hidden="true">✦</span>
|
||||
<div>
|
||||
<h3>Van studio naar spotlight</h3>
|
||||
<p>
|
||||
Voeg je dansers toe, stel groepen samen en maak alvast een show aan. Zo bouw je stap voor
|
||||
stap aan je volgende optreden.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
@if (editor()) {
|
||||
<app-entity-editor
|
||||
[target]="{ kind: 'school' }"
|
||||
(closed)="editor.set(false)"
|
||||
(saved)="created()"
|
||||
/>
|
||||
}
|
||||
56
agiledancers-fe/src/app/schools/schools-page.ts
Normal file
56
agiledancers-fe/src/app/schools/schools-page.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { SchoolTransfer } from '../shared/school-transfer';
|
||||
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { School } from '../core/api-models';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { apiError } from '../core/api-error';
|
||||
import { EntityEditor } from '../shared/entity-editor';
|
||||
|
||||
@Component({
|
||||
selector: 'app-schools-page',
|
||||
imports: [SchoolTransfer, RouterLink, EntityEditor],
|
||||
templateUrl: './schools-page.html',
|
||||
})
|
||||
export class SchoolsPage implements OnInit {
|
||||
private readonly api = inject(SchoolApi);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
readonly schools = signal<School[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal('');
|
||||
readonly editor = signal(false);
|
||||
readonly notice = signal('');
|
||||
readonly totalDancers = computed(() =>
|
||||
this.schools().reduce((sum, school) => sum + school.dancerIds.length, 0),
|
||||
);
|
||||
readonly totalGroups = computed(() =>
|
||||
this.schools().reduce((sum, school) => sum + school.danceGroupIds.length, 0),
|
||||
);
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.api
|
||||
.schools()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (schools) => {
|
||||
this.schools.set(schools);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (error) => {
|
||||
this.error.set(apiError(error));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
created(): void {
|
||||
this.editor.set(false);
|
||||
this.notice.set(
|
||||
'Dansschool aangemaakt. Open de school om dansers, groepen en shows toe te voegen.',
|
||||
);
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
40
agiledancers-fe/src/app/shared/confirm-dialog.ts
Normal file
40
agiledancers-fe/src/app/shared/confirm-dialog.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { DialogFrame } from './dialog-frame';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
imports: [DialogFrame],
|
||||
template: `
|
||||
<app-dialog-frame title="Verwijderen bevestigen" [busy]="busy()" (dismissed)="closed.emit()">
|
||||
<p>
|
||||
Weet je zeker dat je <strong>{{ name() }}</strong> wilt verwijderen?
|
||||
</p>
|
||||
<p class="muted">{{ explanation() }}</p>
|
||||
@if (error()) {
|
||||
<p class="alert error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
<div class="dialog-actions">
|
||||
<button
|
||||
autofocus
|
||||
type="button"
|
||||
class="button secondary"
|
||||
[disabled]="busy()"
|
||||
(click)="closed.emit()"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
<button type="button" class="button danger" [disabled]="busy()" (click)="confirmed.emit()">
|
||||
{{ busy() ? 'Verwijderen…' : 'Definitief verwijderen' }}
|
||||
</button>
|
||||
</div>
|
||||
</app-dialog-frame>
|
||||
`,
|
||||
})
|
||||
export class ConfirmDialog {
|
||||
readonly name = input.required<string>();
|
||||
readonly explanation = input('Dit kan niet ongedaan worden gemaakt.');
|
||||
readonly busy = input(false);
|
||||
readonly error = input('');
|
||||
readonly confirmed = output<void>();
|
||||
readonly closed = output<void>();
|
||||
}
|
||||
50
agiledancers-fe/src/app/shared/dialog-frame.ts
Normal file
50
agiledancers-fe/src/app/shared/dialog-frame.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
input,
|
||||
OnDestroy,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dialog-frame',
|
||||
template: `
|
||||
<dialog #dialog (cancel)="cancel($event)" aria-labelledby="dialog-title">
|
||||
<div class="dialog-heading">
|
||||
<div>
|
||||
<p class="eyebrow">AGILE DANCERS</p>
|
||||
<h2 id="dialog-title">{{ title() }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
aria-label="Sluiten"
|
||||
[disabled]="busy()"
|
||||
(click)="dismissed.emit()"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<ng-content />
|
||||
</dialog>
|
||||
`,
|
||||
})
|
||||
export class DialogFrame implements AfterViewInit, OnDestroy {
|
||||
readonly title = input.required<string>();
|
||||
readonly busy = input(false);
|
||||
readonly dismissed = output<void>();
|
||||
private readonly dialog = viewChild.required<ElementRef<HTMLDialogElement>>('dialog');
|
||||
ngAfterViewInit(): void {
|
||||
this.dialog().nativeElement.showModal();
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
const dialog = this.dialog().nativeElement;
|
||||
if (dialog.open) dialog.close();
|
||||
}
|
||||
cancel(event: Event): void {
|
||||
event.preventDefault();
|
||||
if (!this.busy()) this.dismissed.emit();
|
||||
}
|
||||
}
|
||||
69
agiledancers-fe/src/app/shared/entity-editor.spec.ts
Normal file
69
agiledancers-fe/src/app/shared/entity-editor.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { EntityEditor } from './entity-editor';
|
||||
import { EditorTarget } from '../core/api-models';
|
||||
|
||||
describe('EntityEditor', () => {
|
||||
const api = { saveSchool: vi.fn(), saveGroup: vi.fn(), saveDancer: vi.fn(), saveShow: vi.fn() };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EntityEditor],
|
||||
providers: [{ provide: SchoolApi, useValue: api }],
|
||||
});
|
||||
});
|
||||
function editor(target: EditorTarget) {
|
||||
const fixture = TestBed.createComponent(EntityEditor);
|
||||
fixture.componentRef.setInput('target', target);
|
||||
fixture.componentRef.setInput('schoolId', 7);
|
||||
fixture.componentInstance.ngOnInit();
|
||||
return fixture.componentInstance;
|
||||
}
|
||||
|
||||
it('rejects blank names and invalid age ranges before calling the API', async () => {
|
||||
const component = editor({ kind: 'group' });
|
||||
component.name = ' ';
|
||||
expect(component.validDetails()).toBe(false);
|
||||
component.name = 'Teens';
|
||||
component.startAge = 18;
|
||||
component.endAge = 12;
|
||||
await component.save();
|
||||
expect(api.saveGroup).not.toHaveBeenCalled();
|
||||
component.endAge = 18.5;
|
||||
expect(component.validDetails()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not accept a future birth date', () => {
|
||||
const component = editor({ kind: 'dancer' });
|
||||
component.name = 'Sam';
|
||||
component.date = '2999-01-01';
|
||||
expect(component.validDetails()).toBe(false);
|
||||
});
|
||||
|
||||
it('trims the school name and emits saved only after success', async () => {
|
||||
api.saveSchool.mockReturnValue(of({ id: 0 }));
|
||||
const component = editor({ kind: 'school' });
|
||||
component.name = ' Studio ';
|
||||
const saved = vi.fn();
|
||||
component.saved.subscribe(saved);
|
||||
await component.save();
|
||||
expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined);
|
||||
expect(saved).toHaveBeenCalledOnce();
|
||||
expect(component.busy()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the editor open and preserves input when saving fails', async () => {
|
||||
api.saveSchool.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
|
||||
const component = editor({ kind: 'school' });
|
||||
component.name = 'Studio';
|
||||
const saved = vi.fn();
|
||||
component.saved.subscribe(saved);
|
||||
await component.save();
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
expect(component.name).toBe('Studio');
|
||||
expect(component.error()).toContain('niet bereikbaar');
|
||||
expect(component.busy()).toBe(false);
|
||||
});
|
||||
});
|
||||
222
agiledancers-fe/src/app/shared/entity-editor.ts
Normal file
222
agiledancers-fe/src/app/shared/entity-editor.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { firstValueFrom, Observable } from 'rxjs';
|
||||
import { EditorTarget, GROUP_STYLES, GROUP_TYPES, GroupStyle, GroupType } from '../core/api-models';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { apiError } from '../core/api-error';
|
||||
import { DialogFrame } from './dialog-frame';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-editor',
|
||||
imports: [FormsModule, DialogFrame],
|
||||
template: `
|
||||
<app-dialog-frame [title]="title" [busy]="busy()" (dismissed)="closed.emit()">
|
||||
<form #form="ngForm" (ngSubmit)="form.valid && save()">
|
||||
@if (error()) {
|
||||
<p class="alert error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
@if (target().kind === 'group') {
|
||||
<label
|
||||
>Naam / categorie<input
|
||||
autofocus
|
||||
name="category"
|
||||
[(ngModel)]="name"
|
||||
required
|
||||
pattern=".*\\S.*"
|
||||
maxlength="160"
|
||||
placeholder="Bijvoorbeeld: Hiphop tieners"
|
||||
/></label>
|
||||
<label for="group-type">Type</label
|
||||
><select id="group-type" name="type" [(ngModel)]="type" required>
|
||||
@for (option of types; track option.value) {
|
||||
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
<div class="form-grid">
|
||||
<label
|
||||
>Leeftijd vanaf<input
|
||||
type="number"
|
||||
name="startAge"
|
||||
[(ngModel)]="startAge"
|
||||
required
|
||||
min="0"
|
||||
step="1"
|
||||
/></label>
|
||||
<label
|
||||
>Leeftijd tot en met<input
|
||||
type="number"
|
||||
name="endAge"
|
||||
[(ngModel)]="endAge"
|
||||
required
|
||||
[min]="startAge ?? 0"
|
||||
step="1"
|
||||
/></label>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>Dansstijlen <span class="muted">(optioneel)</span></legend>
|
||||
<div class="checkbox-grid">
|
||||
@for (style of styles; track style.value) {
|
||||
<label class="checkbox"
|
||||
><input
|
||||
type="checkbox"
|
||||
[name]="style.value"
|
||||
[ngModel]="selectedStyles.includes(style.value)"
|
||||
(ngModelChange)="toggleStyle(style.value, $event)"
|
||||
/>{{ style.label }}</label
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
} @else {
|
||||
<label
|
||||
>Naam<input
|
||||
autofocus
|
||||
name="name"
|
||||
[(ngModel)]="name"
|
||||
required
|
||||
pattern=".*\\S.*"
|
||||
maxlength="160"
|
||||
[placeholder]="target().kind === 'school' ? 'Bijvoorbeeld: 24 Dance Studio' : 'Naam'"
|
||||
/></label>
|
||||
@if (target().kind === 'dancer') {
|
||||
<label
|
||||
>Geboortedatum<input
|
||||
type="date"
|
||||
name="birthDate"
|
||||
[(ngModel)]="date"
|
||||
required
|
||||
[max]="today"
|
||||
/></label>
|
||||
}
|
||||
@if (target().kind === 'show') {
|
||||
<label>Datum<input type="date" name="showDate" [(ngModel)]="date" required /></label>
|
||||
<p class="form-hint">
|
||||
De show krijgt automatisch een ochtend-, middag- en avonddeel. De groepsindeling volgt
|
||||
later.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
<div class="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button secondary"
|
||||
[disabled]="busy()"
|
||||
(click)="closed.emit()"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
<button
|
||||
class="button primary"
|
||||
type="submit"
|
||||
[disabled]="busy() || form.invalid || !validDetails()"
|
||||
>
|
||||
{{ busy() ? 'Opslaan…' : 'Opslaan' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</app-dialog-frame>
|
||||
`,
|
||||
})
|
||||
export class EntityEditor implements OnInit {
|
||||
readonly target = input.required<EditorTarget>();
|
||||
readonly schoolId = input<number>();
|
||||
readonly closed = output<void>();
|
||||
readonly saved = output<void>();
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly types = GROUP_TYPES;
|
||||
readonly styles = GROUP_STYLES;
|
||||
readonly today = new Date().toLocaleDateString('en-CA');
|
||||
private readonly api = inject(SchoolApi);
|
||||
name = '';
|
||||
date = '';
|
||||
type: GroupType = 'REGULAR';
|
||||
startAge: number | null = 0;
|
||||
endAge: number | null = 18;
|
||||
selectedStyles: GroupStyle[] = [];
|
||||
|
||||
get title(): string {
|
||||
const labels = { school: 'Dansschool', dancer: 'Danser', group: 'Dansgroep', show: 'Show' };
|
||||
return `${labels[this.target().kind]} ${this.target().value ? 'bewerken' : 'toevoegen'}`;
|
||||
}
|
||||
ngOnInit(): void {
|
||||
const target = this.target();
|
||||
if (!target.value) return;
|
||||
if (target.kind === 'group') {
|
||||
this.name = target.value.category;
|
||||
this.type = target.value.type;
|
||||
this.startAge = target.value.startAge;
|
||||
this.endAge = target.value.endAge;
|
||||
this.selectedStyles = [...target.value.styles];
|
||||
} else {
|
||||
this.name = target.value.name;
|
||||
if (target.kind === 'dancer') this.date = target.value.birthDate;
|
||||
if (target.kind === 'show') this.date = target.value.date;
|
||||
}
|
||||
}
|
||||
toggleStyle(style: GroupStyle, checked: boolean): void {
|
||||
this.selectedStyles = checked
|
||||
? [...this.selectedStyles, style]
|
||||
: this.selectedStyles.filter((value) => value !== style);
|
||||
}
|
||||
validDetails(): boolean {
|
||||
if (!this.name.trim()) return false;
|
||||
if (this.target().kind === 'dancer') return !!this.date && this.date <= this.today;
|
||||
if (this.target().kind === 'show') return !!this.date;
|
||||
if (this.target().kind === 'group')
|
||||
return (
|
||||
this.startAge !== null &&
|
||||
this.endAge !== null &&
|
||||
Number.isInteger(this.startAge) &&
|
||||
Number.isInteger(this.endAge) &&
|
||||
this.startAge >= 0 &&
|
||||
this.endAge >= this.startAge
|
||||
);
|
||||
return true;
|
||||
}
|
||||
async save(): Promise<void> {
|
||||
if (this.busy() || !this.validDetails()) return;
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
const target = this.target();
|
||||
const id = target.value?.id;
|
||||
const schoolId = this.schoolId();
|
||||
let request: Observable<unknown>;
|
||||
switch (target.kind) {
|
||||
case 'school':
|
||||
request = this.api.saveSchool({ name: this.name.trim() }, id);
|
||||
break;
|
||||
case 'dancer':
|
||||
request = this.api.saveDancer(
|
||||
schoolId!,
|
||||
{ name: this.name.trim(), birthDate: this.date },
|
||||
id,
|
||||
);
|
||||
break;
|
||||
case 'group':
|
||||
request = this.api.saveGroup(
|
||||
schoolId!,
|
||||
{
|
||||
category: this.name.trim(),
|
||||
type: this.type,
|
||||
startAge: this.startAge!,
|
||||
endAge: this.endAge!,
|
||||
styles: this.selectedStyles,
|
||||
},
|
||||
id,
|
||||
);
|
||||
break;
|
||||
case 'show':
|
||||
request = this.api.saveShow(schoolId!, { name: this.name.trim(), date: this.date }, id);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await firstValueFrom(request);
|
||||
this.saved.emit();
|
||||
} catch (error: unknown) {
|
||||
this.error.set(apiError(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
agiledancers-fe/src/app/shared/group-members.spec.ts
Normal file
76
agiledancers-fe/src/app/shared/group-members.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Subject } from 'rxjs';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { GroupMembers } from './group-members';
|
||||
|
||||
describe('Group membership persistence', () => {
|
||||
const api = { membership: vi.fn() };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [GroupMembers],
|
||||
providers: [{ provide: SchoolApi, useValue: api }],
|
||||
});
|
||||
});
|
||||
function component() {
|
||||
const fixture = TestBed.createComponent(GroupMembers);
|
||||
fixture.componentRef.setInput('schoolId', 7);
|
||||
fixture.componentRef.setInput('group', {
|
||||
id: 0,
|
||||
category: 'Teens',
|
||||
type: 'REGULAR',
|
||||
startAge: 12,
|
||||
endAge: 18,
|
||||
styles: [],
|
||||
dancerIds: [],
|
||||
});
|
||||
fixture.componentRef.setInput('dancers', [{ id: 3, name: 'Sam', birthDate: '2010-01-01' }]);
|
||||
fixture.componentInstance.ngOnInit();
|
||||
return fixture.componentInstance;
|
||||
}
|
||||
function change(checked: boolean): { checkbox: HTMLInputElement; event: Event } {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = checked;
|
||||
const event = new Event('change');
|
||||
Object.defineProperty(event, 'target', { value: checkbox });
|
||||
return { checkbox, event };
|
||||
}
|
||||
|
||||
it('shows the pending choice but emits new membership only after the server confirms', async () => {
|
||||
const request = new Subject<void>();
|
||||
api.membership.mockReturnValue(request);
|
||||
const members = component();
|
||||
const changed = vi.fn();
|
||||
members.changed.subscribe(changed);
|
||||
const { checkbox, event } = change(true);
|
||||
const pending = members.toggle(3, event);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(members.busy()).toBe(true);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
request.next();
|
||||
request.complete();
|
||||
await pending;
|
||||
expect(changed).toHaveBeenCalledWith([3]);
|
||||
expect(members.memberIds()).toEqual([3]);
|
||||
expect(members.busy()).toBe(false);
|
||||
});
|
||||
|
||||
it('restores the previous checkbox state when saving fails', async () => {
|
||||
const request = new Subject<void>();
|
||||
api.membership.mockReturnValue(request);
|
||||
const members = component();
|
||||
const changed = vi.fn();
|
||||
members.changed.subscribe(changed);
|
||||
const { checkbox, event } = change(true);
|
||||
const pending = members.toggle(3, event);
|
||||
request.error(new HttpErrorResponse({ status: 0 }));
|
||||
await pending;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(members.memberIds()).toEqual([]);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
expect(members.error()).toContain('niet bereikbaar');
|
||||
expect(members.busy()).toBe(false);
|
||||
});
|
||||
});
|
||||
98
agiledancers-fe/src/app/shared/group-members.ts
Normal file
98
agiledancers-fe/src/app/shared/group-members.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Component, computed, inject, input, OnInit, output, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { DanceGroup, Dancer } from '../core/api-models';
|
||||
import { SchoolApi } from '../core/school-api';
|
||||
import { apiError } from '../core/api-error';
|
||||
import { DialogFrame } from './dialog-frame';
|
||||
|
||||
@Component({
|
||||
selector: 'app-group-members',
|
||||
imports: [DialogFrame],
|
||||
template: `
|
||||
<app-dialog-frame
|
||||
[title]="'Dansers in ' + group().category"
|
||||
[busy]="busy()"
|
||||
(dismissed)="closed.emit()"
|
||||
>
|
||||
<p class="muted">
|
||||
Koppel dansers aan deze groep. Een danser kan in meerdere groepen zitten. Wijzigingen worden
|
||||
direct opgeslagen.
|
||||
</p>
|
||||
@if (error()) {
|
||||
<p class="alert error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
<p class="member-count" role="status">
|
||||
{{
|
||||
busy()
|
||||
? 'Koppeling opslaan…'
|
||||
: memberIds().length + ' van ' + dancers().length + ' dansers gekoppeld'
|
||||
}}
|
||||
</p>
|
||||
<div class="member-list" [attr.aria-busy]="busy()">
|
||||
@for (dancer of sortedDancers(); track dancer.id) {
|
||||
<label class="member-row"
|
||||
><span aria-hidden="true" class="avatar small">{{
|
||||
dancer.name.charAt(0).toUpperCase()
|
||||
}}</span>
|
||||
<span>{{ dancer.name }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="memberIds().includes(dancer.id)"
|
||||
[disabled]="busy()"
|
||||
(change)="toggle(dancer.id, $event)"
|
||||
/>
|
||||
</label>
|
||||
} @empty {
|
||||
<p class="empty-inline">
|
||||
Er zijn nog geen dansers. Voeg eerst een danser toe via het tabblad Dansers.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="button primary" type="button" [disabled]="busy()" (click)="closed.emit()">
|
||||
Gereed
|
||||
</button>
|
||||
</div>
|
||||
</app-dialog-frame>
|
||||
`,
|
||||
})
|
||||
export class GroupMembers implements OnInit {
|
||||
readonly schoolId = input.required<number>();
|
||||
readonly group = input.required<DanceGroup>();
|
||||
readonly dancers = input.required<Dancer[]>();
|
||||
readonly closed = output<void>();
|
||||
readonly changed = output<number[]>();
|
||||
readonly memberIds = signal<number[]>([]);
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly sortedDancers = computed(() =>
|
||||
[...this.dancers()].sort((a, b) => a.name.localeCompare(b.name, 'nl')),
|
||||
);
|
||||
private readonly api = inject(SchoolApi);
|
||||
ngOnInit(): void {
|
||||
this.memberIds.set([...this.group().dancerIds]);
|
||||
}
|
||||
async toggle(dancerId: number, event: Event): Promise<void> {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
const add = checkbox.checked;
|
||||
if (this.busy()) {
|
||||
checkbox.checked = this.memberIds().includes(dancerId);
|
||||
return;
|
||||
}
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
try {
|
||||
await firstValueFrom(this.api.membership(this.schoolId(), this.group().id, dancerId, add));
|
||||
this.memberIds.update((ids) =>
|
||||
add ? [...ids, dancerId] : ids.filter((id) => id !== dancerId),
|
||||
);
|
||||
checkbox.checked = add;
|
||||
this.changed.emit(this.memberIds());
|
||||
} catch (error: unknown) {
|
||||
checkbox.checked = this.memberIds().includes(dancerId);
|
||||
this.error.set(apiError(error));
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
agiledancers-fe/src/app/shared/school-transfer.spec.ts
Normal file
86
agiledancers-fe/src/app/shared/school-transfer.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SchoolTransfer } from './school-transfer';
|
||||
|
||||
describe('School JSON import', () => {
|
||||
let http: HttpTestingController;
|
||||
let component: SchoolTransfer;
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
component = TestBed.createComponent(SchoolTransfer).componentInstance;
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
async function select() {
|
||||
const selected = component.select({
|
||||
target: { files: [{ text: async () => '{"formatVersion":1}' }], value: 'file.json' },
|
||||
} as unknown as Event);
|
||||
await Promise.resolve();
|
||||
return { selected, request: http.expectOne('/api/dance-schools/import/preview') };
|
||||
}
|
||||
it('previews without committing and cancellation makes no write request', async () => {
|
||||
const { selected, request } = await select();
|
||||
request.flush({
|
||||
name: 'Studio',
|
||||
dancers: 1,
|
||||
groups: 2,
|
||||
shows: 3,
|
||||
existingId: null,
|
||||
existingName: null,
|
||||
});
|
||||
await selected;
|
||||
expect(component.preview()?.name).toBe('Studio');
|
||||
http.expectNone('/api/dance-schools/import');
|
||||
component.cancel();
|
||||
await component.confirm();
|
||||
expect(component.preview()).toBeNull();
|
||||
});
|
||||
it('sends the identified school only after confirmation', async () => {
|
||||
const { selected, request } = await select();
|
||||
request.flush({
|
||||
name: 'Studio',
|
||||
dancers: 1,
|
||||
groups: 2,
|
||||
shows: 3,
|
||||
existingId: 0,
|
||||
existingName: 'Old studio',
|
||||
});
|
||||
await selected;
|
||||
const completed = vi.fn();
|
||||
component.imported.subscribe(completed);
|
||||
const commit = component.confirm();
|
||||
const write = http.expectOne('/api/dance-schools/import?replaceId=0');
|
||||
expect(write.request.body).toBe('{"formatVersion":1}');
|
||||
write.flush({});
|
||||
await commit;
|
||||
expect(completed).toHaveBeenCalledOnce();
|
||||
expect(component.preview()).toBeNull();
|
||||
});
|
||||
it('shows validation errors and allows retrying a corrected file', async () => {
|
||||
const { selected, request } = await select();
|
||||
request.flush(
|
||||
{ message: 'groep Hiphop verwijst naar een onbekende danser' },
|
||||
{ status: 400, statusText: 'Bad Request' },
|
||||
);
|
||||
await selected;
|
||||
expect(component.error()).toBe(
|
||||
'Import niet gelukt: groep Hiphop verwijst naar een onbekende danser',
|
||||
);
|
||||
expect(component.busy()).toBe(false);
|
||||
const retry = await select();
|
||||
retry.request.flush({
|
||||
name: 'Corrected',
|
||||
dancers: 0,
|
||||
groups: 0,
|
||||
shows: 0,
|
||||
existingId: null,
|
||||
existingName: null,
|
||||
});
|
||||
await retry.selected;
|
||||
expect(component.error()).toBe('');
|
||||
expect(component.preview()?.name).toBe('Corrected');
|
||||
});
|
||||
});
|
||||
152
agiledancers-fe/src/app/shared/school-transfer.ts
Normal file
152
agiledancers-fe/src/app/shared/school-transfer.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Component, inject, input, output, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { apiError } from '../core/api-error';
|
||||
|
||||
interface Preview {
|
||||
name: string;
|
||||
dancers: number;
|
||||
groups: number;
|
||||
shows: number;
|
||||
existingId: number | null;
|
||||
existingName: string | null;
|
||||
}
|
||||
@Component({
|
||||
selector: 'app-school-transfer',
|
||||
template: `
|
||||
<div class="quick-actions">
|
||||
@if (schoolId() !== undefined) {
|
||||
<button type="button" class="button secondary" (click)="download()" [disabled]="busy()">
|
||||
JSON exporteren
|
||||
</button>
|
||||
}
|
||||
<label
|
||||
>JSON importeren
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
[disabled]="busy()"
|
||||
(change)="select($event)"
|
||||
/></label>
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="alert error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
@if (notice()) {
|
||||
<p role="status">{{ notice() }}</p>
|
||||
}
|
||||
@if (preview(); as p) {
|
||||
<section class="panel" aria-label="Importvoorbeeld">
|
||||
<h2>{{ p.name }}</h2>
|
||||
<p>{{ p.dancers }} dansers · {{ p.groups }} dansgroepen · {{ p.shows }} shows</p>
|
||||
@if (p.existingId !== null) {
|
||||
<p>
|
||||
De bestaande school {{ p.existingName }} wordt volledig vervangen. Alle huidige gegevens
|
||||
van deze school verdwijnen.
|
||||
</p>
|
||||
<button type="button" class="button primary" [disabled]="busy()" (click)="confirm()">
|
||||
Volledig vervangen bevestigen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button secondary"
|
||||
[disabled]="busy()"
|
||||
(click)="notice.set('Samenvoegen wordt nog niet ondersteund.')"
|
||||
>
|
||||
Samenvoegen
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="button primary" [disabled]="busy()" (click)="confirm()">
|
||||
School toevoegen bevestigen
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="button secondary" [disabled]="busy()" (click)="cancel()">
|
||||
Annuleren
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SchoolTransfer {
|
||||
readonly schoolId = input<number>();
|
||||
readonly imported = output<void>();
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly preview = signal<Preview | null>(null);
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly notice = signal('');
|
||||
private json = '';
|
||||
private readonly base = '/api/dance-schools';
|
||||
|
||||
cancel(): void {
|
||||
this.preview.set(null);
|
||||
this.json = '';
|
||||
this.notice.set('');
|
||||
}
|
||||
async select(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
this.cancel();
|
||||
this.error.set('');
|
||||
if (!file) return;
|
||||
this.busy.set(true);
|
||||
try {
|
||||
this.json = await file.text();
|
||||
this.preview.set(
|
||||
await firstValueFrom(
|
||||
this.http.post<Preview>(`${this.base}/import/preview`, this.json, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
this.error.set(
|
||||
`Import niet gelukt: ${e instanceof Error && !('status' in e) ? e.message : apiError(e)}`,
|
||||
);
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
async confirm(): Promise<void> {
|
||||
const p = this.preview();
|
||||
if (!p || this.busy()) return;
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
try {
|
||||
const query = p.existingId === null ? '' : `?replaceId=${p.existingId}`;
|
||||
await firstValueFrom(
|
||||
this.http.post(`${this.base}/import${query}`, this.json, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
this.cancel();
|
||||
this.notice.set('School geïmporteerd.');
|
||||
this.imported.emit();
|
||||
} catch (e) {
|
||||
this.error.set(`Import niet gelukt: ${apiError(e)}`);
|
||||
this.preview.set(null);
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
async download(): Promise<void> {
|
||||
this.error.set('');
|
||||
this.busy.set(true);
|
||||
try {
|
||||
const blob = await firstValueFrom(
|
||||
this.http.get(`${this.base}/${this.schoolId()}/export`, { responseType: 'blob' }),
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `school-${this.schoolId()}.json`;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (e) {
|
||||
this.error.set(`Export niet gelukt: ${apiError(e)}`);
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
agiledancers-fe/src/index.html
Normal file
13
agiledancers-fe/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Agile Dancers · Dansschoolbeheer</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
5
agiledancers-fe/src/main.ts
Normal file
5
agiledancers-fe/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||
1023
agiledancers-fe/src/styles.css
Normal file
1023
agiledancers-fe/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user