Skip to content

Commit

Permalink
Artefact signoff frontend (#90)
Browse files Browse the repository at this point in the history
* Refactor big file into smaller files

* Store artefact status

* Add UI for artefact sign off

* Add artefact status chip on artefact cards

* Add frontend functionality to change artefact status

* Return updated artefact on patch endpoint

* Pass correct artefact status value

* fix order of returned artefacts
  • Loading branch information
omar-selo authored Dec 15, 2023
1 parent 0f99704 commit 7663f76
Show file tree
Hide file tree
Showing 14 changed files with 289 additions and 122 deletions.
19 changes: 16 additions & 3 deletions backend/test_observer/controllers/artefacts/artefacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,23 @@
def get_artefacts(family: FamilyName | None = None, db: Session = Depends(get_db)):
"""Get latest artefacts optionally by family"""
artefacts = []
order_by = (Artefact.name, Artefact.created_at)

if family:
artefacts = get_artefacts_by_family(db, family, load_stage=True)
artefacts = get_artefacts_by_family(
db,
family,
load_stage=True,
order_by_columns=order_by,
)
else:
for family in FamilyName:
artefacts += get_artefacts_by_family(db, family, load_stage=True)
artefacts += get_artefacts_by_family(
db,
family,
load_stage=True,
order_by_columns=order_by,
)

return artefacts

Expand All @@ -55,7 +66,7 @@ def get_artefact(artefact_id: int, db: Session = Depends(get_db)):
return artefact


@router.patch("/{artefact_id}")
@router.patch("/{artefact_id}", response_model=ArtefactDTO)
def patch_artefact(
artefact_id: int, request: ArtefactPatch, db: Session = Depends(get_db)
):
Expand All @@ -67,6 +78,8 @@ def patch_artefact(
artefact.status = request.status
db.commit()

return artefact


@router.get("/{artefact_id}/builds", response_model=list[ArtefactBuildDTO])
def get_artefact_builds(artefact_id: int, db: Session = Depends(get_db)):
Expand Down
7 changes: 7 additions & 0 deletions backend/test_observer/data_access/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"""Services for working with objects from DB"""


from collections.abc import Iterable
from typing import Any

from sqlalchemy import and_, func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
Expand Down Expand Up @@ -52,6 +55,7 @@ def get_artefacts_by_family(
family_name: FamilyName,
latest_only: bool = True,
load_stage: bool = False,
order_by_columns: Iterable[Any] | None = None,
) -> list[Artefact]:
"""
Get all the artefacts
Expand Down Expand Up @@ -120,6 +124,9 @@ def get_artefacts_by_family(
if load_stage:
query = query.options(joinedload(Artefact.stage))

if order_by_columns:
query = query.order_by(*order_by_columns)

return query.all()


Expand Down
11 changes: 11 additions & 0 deletions backend/tests/controllers/artefacts/test_artefacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,14 @@ def test_artefact_signoff(db_session: Session, test_client: TestClient):

assert response.status_code == 200
assert artefact.status == ArtefactStatus.APPROVED
assert response.json() == {
"id": artefact.id,
"name": artefact.name,
"version": artefact.version,
"track": artefact.track,
"store": artefact.store,
"series": artefact.series,
"repo": artefact.repo,
"stage": artefact.stage.name,
"status": artefact.status,
}
45 changes: 45 additions & 0 deletions frontend/lib/models/artefact.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:yaru/yaru.dart';

import 'stage_name.dart';

Expand All @@ -17,6 +19,7 @@ class Artefact with _$Artefact {
@Default(null) String? store,
@Default(null) String? series,
@Default(null) String? repo,
required ArtefactStatus status,
required StageName stage,
}) = _Artefact;

Expand All @@ -31,3 +34,45 @@ class Artefact with _$Artefact {
if (repo != null) 'repo': repo!,
};
}

enum ArtefactStatus {
@JsonValue('APPROVED')
approved,
@JsonValue('MARKED_AS_FAILED')
rejected,
@JsonValue('UNDECIDED')
undecided;

String get name {
switch (this) {
case approved:
return 'Approved';
case rejected:
return 'Rejected';
case undecided:
return 'Undecided';
}
}

Color get color {
switch (this) {
case approved:
return YaruColors.light.success;
case rejected:
return YaruColors.red;
case undecided:
return YaruColors.textGrey;
}
}

String toJson() {
switch (this) {
case approved:
return 'APPROVED';
case rejected:
return 'MARKED_AS_FAILED';
case undecided:
return 'UNDECIDED';
}
}
}
15 changes: 0 additions & 15 deletions frontend/lib/providers/artefact.dart

This file was deleted.

29 changes: 29 additions & 0 deletions frontend/lib/providers/artefact_notifier.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../models/artefact.dart';
import 'dio.dart';

part 'artefact_notifier.g.dart';

@riverpod
class ArtefactNotifier extends _$ArtefactNotifier {
@override
Future<Artefact> build(int artefactId) async {
final dio = ref.watch(dioProvider);

final response = await dio.get('/v1/artefacts/$artefactId');
final artefact = Artefact.fromJson(response.data);
return artefact;
}

Future<void> changeStatus(ArtefactStatus newStatus) async {
final dio = ref.watch(dioProvider);

final response = await dio.patch(
'/v1/artefacts/$artefactId',
data: {'status': newStatus.toJson()},
);

state = AsyncData(Artefact.fromJson(response.data));
}
}
4 changes: 2 additions & 2 deletions frontend/lib/routing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ final appRouter = GoRouter(
path: ':artefactId',
pageBuilder: (context, state) => DialogPage(
builder: (_) => ArtefactDialog(
artefactId: state.pathParameters['artefactId']!,
artefactId: int.parse(state.pathParameters['artefactId']!),
),
),
),
Expand All @@ -44,7 +44,7 @@ final appRouter = GoRouter(
path: ':artefactId',
pageBuilder: (context, state) => DialogPage(
builder: (_) => ArtefactDialog(
artefactId: state.pathParameters['artefactId']!,
artefactId: int.parse(state.pathParameters['artefactId']!),
),
),
),
Expand Down
10 changes: 5 additions & 5 deletions frontend/lib/ui/artefact_dialog/artefact_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yaru_widgets/widgets.dart';

import '../../providers/artefact.dart';
import '../../providers/artefact_notifier.dart';
import '../spacing.dart';
import 'artefact_dialog_body.dart';
import 'artefact_dialog_headert.dart';
import 'artefact_dialog_header.dart';
import 'artefact_dialog_info_section.dart';

class ArtefactDialog extends ConsumerWidget {
const ArtefactDialog({super.key, required this.artefactId});

final String artefactId;
final int artefactId;

@override
Widget build(BuildContext context, WidgetRef ref) {
final artefact = ref.watch(artefactProvider(artefactId));
final artefact = ref.watch(artefactNotifierProvider(artefactId));

return SelectionArea(
child: Dialog(
Expand All @@ -33,7 +33,7 @@ class ArtefactDialog extends ConsumerWidget {
data: (artefact) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ArtefactDialogHeader(title: artefact.name),
ArtefactDialogHeader(artefact: artefact),
const SizedBox(height: Spacing.level4),
ArtefactDialogInfoSection(artefact: artefact),
const SizedBox(height: Spacing.level4),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:yaru_icons/yaru_icons.dart';

import '../../models/artefact.dart';
import '../spacing.dart';
import 'artefact_signoff_button.dart';

class ArtefactDialogHeader extends StatelessWidget {
const ArtefactDialogHeader({super.key, required this.title});
const ArtefactDialogHeader({super.key, required this.artefact});

final String title;
final Artefact artefact;

@override
Widget build(BuildContext context) {
Expand All @@ -18,9 +20,11 @@ class ArtefactDialogHeader extends StatelessWidget {
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: Theme.of(context).textTheme.headlineLarge),
Text(artefact.name, style: Theme.of(context).textTheme.headlineLarge),
const SizedBox(width: Spacing.level4),
ArtefactSignoffButton(artefact: artefact),
const Spacer(),
InkWell(
child: const Icon(
YaruIcons.window_close,
Expand Down
38 changes: 38 additions & 0 deletions frontend/lib/ui/artefact_dialog/artefact_signoff_button.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yaru_widgets/yaru_widgets.dart';

import '../../models/artefact.dart';
import '../../providers/artefact_notifier.dart';

class ArtefactSignoffButton extends ConsumerWidget {
const ArtefactSignoffButton({super.key, required this.artefact});

final Artefact artefact;

@override
Widget build(BuildContext context, WidgetRef ref) {
final fontStyle = Theme.of(context).textTheme.titleMedium;

return YaruPopupMenuButton(
child: Text(
artefact.status.name,
style: fontStyle?.apply(color: artefact.status.color),
),
itemBuilder: (_) => ArtefactStatus.values
.map(
(status) => PopupMenuItem(
value: status,
onTap: () => ref
.read(artefactNotifierProvider(artefact.id).notifier)
.changeStatus(status),
child: Text(
status.name,
style: fontStyle?.apply(color: status.color),
),
),
)
.toList(),
);
}
}
55 changes: 55 additions & 0 deletions frontend/lib/ui/dashboard/artefact_card.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intersperse/intersperse.dart';

import '../../models/artefact.dart';
import '../spacing.dart';
import 'artefact_status_chip.dart';

class ArtefactCard extends ConsumerWidget {
const ArtefactCard({Key? key, required this.artefact}) : super(key: key);

final Artefact artefact;
static const double width = 320;

@override
Widget build(BuildContext context, WidgetRef ref) {
return GestureDetector(
onTap: () {
final currentRoute = GoRouterState.of(context).fullPath;
context.go('$currentRoute/${artefact.id}');
},
child: Card(
margin: const EdgeInsets.all(0),
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: Theme.of(context).colorScheme.outline),
borderRadius: BorderRadius.circular(2.25),
),
child: Container(
width: width,
height: 180,
padding: const EdgeInsets.all(Spacing.level4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
artefact.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: Spacing.level2),
...artefact.details.entries
.map<Widget>(
(detail) => Text('${detail.key}: ${detail.value}'),
)
.intersperse(const SizedBox(height: Spacing.level2)),
const Spacer(),
ArtefactStatusChip(status: artefact.status),
],
),
),
),
);
}
}
21 changes: 21 additions & 0 deletions frontend/lib/ui/dashboard/artefact_status_chip.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';

import '../../models/artefact.dart';

class ArtefactStatusChip extends StatelessWidget {
const ArtefactStatusChip({super.key, required this.status});

final ArtefactStatus status;

@override
Widget build(BuildContext context) {
final fontStyle = Theme.of(context).textTheme.labelMedium;
return Chip(
label: Text(
status.name,
style: fontStyle?.apply(color: status.color),
),
shape: const StadiumBorder(),
);
}
}
Loading

0 comments on commit 7663f76

Please sign in to comment.