Added editing and viewing roles

master
John Montagu, the 4th Earl of Sandvich 2024-11-08 12:50:48 -08:00
parent 708aafed9e
commit 5d620c07ed
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
15 changed files with 399 additions and 39 deletions

View File

@ -10,6 +10,8 @@
"dependencies": {
"axios": "^1.7.7",
"bootstrap-icons": "^1.11.3",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"pinia": "^2.2.4",
"v-tooltip": "^2.1.3",
"vue": "^3.5.12",
@ -5138,6 +5140,27 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.46",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.46.tgz",
"integrity": "sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@ -12,11 +12,13 @@
"type-check": "vue-tsc --build --force",
"lint": "eslint . --fix",
"format": "prettier --write src/",
"openapi-generate": "openapi --input 'http://localhost:5000/apidoc/openapi.json' --output src/client --name AvailabilitfClient"
"openapi-generate": "openapi --input 'http://localhost:8000/apidoc/openapi.json' --output src/client --name AvailabilitfClient"
},
"dependencies": {
"axios": "^1.7.7",
"bootstrap-icons": "^1.11.3",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"pinia": "^2.2.4",
"v-tooltip": "^2.1.3",
"vue": "^3.5.12",

View File

@ -12,6 +12,7 @@ export type { OpenAPIConfig } from './core/OpenAPI';
export type { AddPlayerJson } from './models/AddPlayerJson';
export type { CreateTeamJson } from './models/CreateTeamJson';
export type { EditMemberRolesJson } from './models/EditMemberRolesJson';
export type { PutScheduleForm } from './models/PutScheduleForm';
export type { RoleSchema } from './models/RoleSchema';
export { TeamRole } from './models/TeamRole';

View File

@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { RoleSchema } from './RoleSchema';
export type EditMemberRolesJson = {
roles: Array<RoleSchema>;
};

View File

@ -4,6 +4,9 @@
/* eslint-disable */
import type { RoleSchema } from './RoleSchema';
export type ViewTeamMembersResponse = {
availability: number;
createdAt: string;
playtime: number;
roles: Array<RoleSchema>;
steamId: string;
username: string;

View File

@ -4,6 +4,7 @@
/* eslint-disable */
import type { AddPlayerJson } from '../models/AddPlayerJson';
import type { CreateTeamJson } from '../models/CreateTeamJson';
import type { EditMemberRolesJson } from '../models/EditMemberRolesJson';
import type { PutScheduleForm } from '../models/PutScheduleForm';
import type { ViewScheduleResponse } from '../models/ViewScheduleResponse';
import type { ViewTeamMembersResponseList } from '../models/ViewTeamMembersResponseList';
@ -210,6 +211,35 @@ export class DefaultService {
},
});
}
/**
* edit_member_roles <PATCH>
* @param teamId
* @param targetPlayerId
* @param requestBody
* @returns void
* @throws ApiError
*/
public editMemberRoles(
teamId: string,
targetPlayerId: string,
requestBody?: EditMemberRolesJson,
): CancelablePromise<void> {
return this.httpRequest.request({
method: 'PATCH',
url: '/api/team/id/{team_id}/edit-player/{target_player_id}',
path: {
'team_id': teamId,
'target_player_id': targetPlayerId,
},
body: requestBody,
mediaType: 'application/json',
errors: {
403: `Forbidden`,
404: `Not Found`,
422: `Unprocessable Entity`,
},
});
}
/**
* add_player <PUT>
* @param teamId

View File

@ -1,14 +1,74 @@
<script setup lang="ts">
import type { PlayerTeamRole } from "../player";
import { computed, type PropType } from "vue";
import { computed, type PropType, ref, watch } from "vue";
import { useTeamsStore } from "../stores/teams";
import { useRosterStore } from "../stores/roster";
import { type ViewTeamMembersResponse } from "@/client";
import { type ViewTeamMembersResponse, type TeamSchema } from "@/client";
import RoleTag from "../components/RoleTag.vue";
const props = defineProps({
player: Object as PropType<ViewTeamMembersResponse>,
team: Object as PropType<TeamSchema>,
});
const teamsStore = useTeamsStore();
const rosterStore = useRosterStore();
//const roles = computed({
// get: () => ({
// "PocketScout": "",
// "FlankScout": "",
// "PocketSoldier": "",
// "Roamer": "",
// "Demoman": "",
// "Medic": "",
// }),
//});
const isEditing = ref(false);
// this is the roles of the player we are editing
const roles = ref([]);
const updatedRoles = ref([]);
//const rolesMap = reactive({
// "Role.PocketScout": undefined,
// "Role.FlankScout": undefined,
// "Role.PocketSoldier": undefined,
// "Role.Roamer": undefined,
// "Role.Demoman": undefined,
// "Role.Medic": undefined,
//});
const possibleRoles = [
"PocketScout",
"FlankScout",
"PocketSoldier",
"Roamer",
"Demoman",
"Medic",
];
watch(isEditing, (newValue) => {
if (newValue) {
// editing
roles.value = possibleRoles.map((roleName) => {
console.log(roleName);
return props.player.roles
.find((playerRole) => playerRole.role == roleName) ?? undefined;
});
}
});
function updateRoles() {
isEditing.value = false;
updatedRoles.value = roles.value.filter(x => x);
props.player.roles = updatedRoles.value;
console.log(roles.value);
console.log(updatedRoles.value);
teamsStore.updateRoles(props.team.id, props.player.steamId, updatedRoles.value);
}
</script>
<template>
@ -23,24 +83,39 @@ const rosterStore = useRosterStore();
</td>
<td>
<div class="role-icons flex-middle">
<div class="role-buttons" v-if="isEditing">
<RoleTag
v-for="role, i in possibleRoles"
:role="role"
:player="player"
v-model="roles[i]"
/>
</div>
<template v-else>
<i
v-for="role in player.roles"
:class="{
[rosterStore.roleIcons[role.role]]: true,
main: role.is_main,
main: role.isMain,
}"
/>
</template>
</div>
</td>
<td>
{{ player.playtime.toFixed(1) }} hours
</td>
<td>
{{ new Date(player.created_at).toLocaleString() }}
{{ new Date(player.createdAt).toLocaleString() }}
</td>
<td>
<div class="edit-group">
<button>
<template v-if="isEditing">
<button class="editing" @click="updateRoles()">
<i class="bi bi-check-lg" />
</button>
</template>
<button v-else @click="isEditing = true">
<i class="bi bi-pencil-fill edit-icon" />
</button>
</div>
@ -94,7 +169,7 @@ const rosterStore = useRosterStore();
align-items: center;
}
.role-icons {
.role-icons i {
font-size: 24px;
line-height: 0;
color: var(--overlay-0);
@ -104,6 +179,12 @@ const rosterStore = useRosterStore();
color: var(--text);
}
.role-buttons {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-group {
display: flex;
justify-content: end;
@ -122,4 +203,8 @@ const rosterStore = useRosterStore();
.player-card:hover .edit-group > button {
opacity: 1;
}
.edit-group > button.editing {
opacity: 1;
}
</style>

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import { type ViewTeamMembersResponse } from "@/client";
import { useRosterStore } from "../stores/roster";
const rosterStore = useRosterStore();
const props = defineProps({
role: String,
player: Object as PropType<ViewTeamMembersResponse>,
});
const roleObject = defineModel();
function toggle(isMain) {
if (isMain == roleObject.value?.isMain) {
roleObject.value = undefined;
} else {
if (!roleObject.value) {
// create a new role object
roleObject.value = {
role: props.role,
isMain: isMain
}
} else {
roleObject.value.isMain = isMain;
}
}
}
</script>
<template>
<div class="role">
<div
:class="{
'role-info': true,
'unselected': !roleObject,
}"
>
<i
:class="{
[rosterStore.roleIcons[role]]: true,
}"
/>
<span>
{{ rosterStore.roleNames[role] }}
</span>
</div>
<button
:class="{
'center': true,
'selected': roleObject?.isMain
}"
@click="toggle(true)"
>
Main
</button>
<button
:class="{
'right': true,
'selected': !(roleObject?.isMain ?? true)
}"
@click="toggle(false)"
>
Alternate
</button>
</div>
</template>
<style scoped>
.role {
display: flex;
}
.role-info {
width: 100%;
display: flex;
align-items: center;
background-color: var(--mantle);
gap: 8px;
padding: 8px 16px;
border-radius: 8px 0 0 8px;
font-size: 10pt;
line-height: 1em;
}
.role-info.unselected {
color: var(--overlay-0);
}
.role button {
font-size: 10pt;
background-color: var(--mantle);
}
.role button.center {
border-radius: 0;
}
.role button.right {
border-radius: 0 8px 8px 0;
}
.role button.selected {
background-color: var(--accent-transparent);
color: var(--accent);
}
.role i {
line-height: unset;
font-size: 12pt;
}
</style>

View File

@ -27,7 +27,7 @@ onMounted(() => {
v-for="team in teams.teams"
>
<RouterLink :to="'/team/id/' + team.id">
{{ team.team_name }}
{{ team.teamName }}
</RouterLink>
</div>
</div>

View File

@ -4,9 +4,9 @@ import { computed, reactive, ref, type Reactive, type Ref } from "vue";
export const useRosterStore = defineStore("roster", () => {
const neededRoles: Reactive<Array<String>> = reactive([
"Pocket Scout",
"Flank Scout",
"Pocket Soldier",
"PocketScout",
"FlankScout",
"PocketSoldier",
"Roamer",
"Demoman",
"Medic",
@ -171,19 +171,21 @@ export const useRosterStore = defineStore("roster", () => {
});
const roleIcons = reactive({
"Pocket Scout": "tf2-PocketScout",
"Flank Scout": "tf2-FlankScout",
"Pocket Soldier": "tf2-PocketSoldier",
"PocketScout": "tf2-PocketScout",
"FlankScout": "tf2-FlankScout",
"PocketSoldier": "tf2-PocketSoldier",
"Roamer": "tf2-FlankSoldier",
"Demoman": "tf2-Demo",
"Medic": "tf2-Medic",
});
"Role.PocketScout": "tf2-PocketScout",
"Role.FlankScout": "tf2-FlankScout",
"Role.PocketSoldier": "tf2-PocketSoldier",
"Role.Roamer": "tf2-FlankSoldier",
"Role.Demoman": "tf2-Demo",
"Role.Medic": "tf2-Medic",
const roleNames = reactive({
"PocketScout": "Pocket Scout",
"FlankScout": "Flank Scout",
"PocketSoldier": "Pocket Soldier",
"Roamer": "Roamer",
"Demoman": "Demoman",
"Medic": "Medic",
});
function selectPlayerForRole(player: PlayerTeamRole, role: string) {
@ -211,6 +213,7 @@ export const useRosterStore = defineStore("roster", () => {
definitelyAvailable,
canBeAvailable,
roleIcons,
roleNames,
mainRoles,
alternateRoles,
}

View File

@ -30,15 +30,15 @@ export const useScheduleStore = defineStore("schedule", () => {
function getWindowStart(team: TeamSchema) {
// convert local 00:00 to league timezone
let localMidnight = moment().startOf("isoWeek");
let leagueTime = localMidnight.clone().tz(team.tz_timezone);
let leagueTime = localMidnight.clone().tz(team.tzTimezone);
let nextMinuteOffsetTime = leagueTime.clone();
if (nextMinuteOffsetTime.minute() > team.minute_offset) {
if (nextMinuteOffsetTime.minute() > team.minuteOffset) {
nextMinuteOffsetTime.add(1, "hour");
}
nextMinuteOffsetTime.minute(team.minute_offset);
nextMinuteOffsetTime.minute(team.minuteOffset);
const deltaMinutes = nextMinuteOffsetTime.diff(leagueTime, "minutes");

View File

@ -1,5 +1,5 @@
import Cacheable from "@/cacheable";
import { AvailabilitfClient, type TeamSpec, type ViewTeamMembersResponse, type ViewTeamResponse, type ViewTeamsResponse } from "@/client";
import { AvailabilitfClient, type RoleSchema, type TeamSpec, type ViewTeamMembersResponse, type ViewTeamResponse, type ViewTeamsResponse } from "@/client";
import { defineStore } from "pinia";
import { computed, reactive, ref, type Reactive, type Ref } from "vue";
import { useClientStore } from "./client";
@ -47,11 +47,12 @@ export const useTeamsStore = defineStore("teams", () => {
response = response
.map((member): ViewTeamMembersResponse => {
// TODO: snake_case to camelCase
member.roles = member.roles.sort((a, b) => {
if (a.is_main == b.is_main) {
member.roles = member.roles
.sort((a, b) => {
if (a.isMain == b.isMain) {
return 0;
}
return a.is_main ? -1 : 1;
return a.isMain ? -1 : 1;
});
return member;
});
@ -70,6 +71,13 @@ export const useTeamsStore = defineStore("teams", () => {
});
}
async function updateRoles(teamId: number, playerId: number, roles: RoleSchema[]) {
return await client.default
.editMemberRoles(teamId.toString(), playerId.toString(), {
roles,
});
}
return {
teams,
teamMembers,
@ -77,5 +85,6 @@ export const useTeamsStore = defineStore("teams", () => {
fetchTeam,
fetchTeamMembers,
createTeam,
updateRoles
};
});

View File

@ -45,12 +45,15 @@ onMounted(() => {
teamsStore.fetchTeams()
.then((teamsList) => {
options.value = Object.values(teamsList.teams);
// select team with id in query parameter if exists
const queryTeam = teamsList.teams.find(x => x.id == route.query.teamId);
if (queryTeam) {
selectedTeam.value = queryTeam;
schedule.team = queryTeam;
schedule.fetchSchedule();
} else {
selectedTeam.value = options.value[0];
}
});
});
@ -64,7 +67,7 @@ onMounted(() => {
Availability for
<v-select
:options="options"
label="team_name"
label="teamName"
v-model="selectedTeam"
/>
</div>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useRoute, useRouter } from "vue-router";
import { useRoute, useRouter, RouterLink } from "vue-router";
import { useTeamsStore } from "../stores/teams";
import { computed, onMounted } from "vue";
import PlayerTeamCard from "../components/PlayerTeamCard.vue";
@ -12,6 +12,11 @@ const team = computed(() => {
return teamsStore.teams[route.params.id];
});
const availableMembers = computed(() => {
return teamsStore.teamMembers[route.params.id]
.filter((member) => member.availability > 0);
});
onMounted(() => {
teamsStore.fetchTeam(route.params.id)
.then(() => teamsStore.fetchTeamMembers(route.params.id));
@ -22,10 +27,20 @@ onMounted(() => {
<main>
<template v-if="team">
<h1>
{{ team.team_name }}
{{ team.teamName }}
<RouterLink :to="'/schedule?teamId=' + team.id">
<button class="accent">
<i class="bi bi-calendar-fill margin"></i>
View schedule
</button>
</RouterLink>
<em class="aside" v-if="teamsStore.teamMembers[route.params.id]">
{{ teamsStore.teamMembers[route.params.id]?.length }} member(s),
{{ availableMembers?.length }} currently available
</em>
</h1>
<table class="member-table">
<thead>
<!--thead>
<tr>
<th>
Name
@ -40,11 +55,13 @@ onMounted(() => {
Joined
</th>
</tr>
</thead>
</thead-->
<tbody>
<PlayerTeamCard
v-for="member in teamsStore.teamMembers[route.params.id]"
:player="member"
:team="team"
:key="member.username"
/>
</tbody>
</table>
@ -55,6 +72,13 @@ onMounted(() => {
<style scoped>
h1 {
display: flex;
gap: 0.5em;
align-items: center;
}
h1 > em.aside {
font-size: 12pt;
font-style: normal;
}
table.member-table {

View File

@ -274,7 +274,7 @@ def view_team_members(player: Player, team_id: int, **kwargs):
def map_role_to_schema(player_team_role: PlayerTeamRole):
return ViewTeamMembersResponse.RoleSchema(
role=str(player_team_role.role),
role=player_team_role.role.name,
is_main=player_team_role.is_main,
)
@ -297,3 +297,59 @@ def view_team_members(player: Player, team_id: int, **kwargs):
).dict(by_alias=True)
return list(map(map_to_response, player_teams))
class EditMemberRolesJson(BaseModel):
roles: list[ViewTeamMembersResponse.RoleSchema]
@api_team.patch("/id/<team_id>/edit-player/<target_player_id>")
@spec.validate(
resp=Response(
HTTP_204=None,
HTTP_403=None,
HTTP_404=None,
),
operation_id="edit_member_roles"
)
@requires_authentication
def edit_member_roles(
json: EditMemberRolesJson,
player: Player,
team_id: int,
target_player_id: int,
**kwargs,
):
print("hiiii lol")
target_player = db.session.query(
PlayerTeam
).where(
PlayerTeam.player_id == target_player_id
).where(
PlayerTeam.team_id == team_id
).options(
joinedload(PlayerTeam.player),
joinedload(PlayerTeam.player_roles),
).one_or_none()
if not target_player:
abort(401)
# TODO: change this to a MERGE statement
for role in target_player.player_roles:
# delete role if not found in json
f = filter(lambda x: x.role == role.role.name, json.roles)
matched_role = next(f, None)
if not matched_role:
db.session.delete(role)
for schema in json.roles:
role = PlayerTeamRole()
role.player_team = target_player
role.role = PlayerTeamRole.Role[schema.role]
role.is_main = schema.is_main
db.session.merge(role)
db.session.commit()
return make_response({ }, 204)