feat: add entity-scoped application events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3364eacbcc
commit
e787359c54
40 changed files with 2143 additions and 1437 deletions
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
|
|
@ -74,7 +74,7 @@ interviewer review uses
|
|||
Only completed assessment invitations can receive a `passed` or `failed` outcome,
|
||||
and only passed candidates can be scheduled for an interview. Scheduled
|
||||
interviews include the creator plus selected co-interviewers. Attendees manage
|
||||
their invitation through `/api/inbox`, see accepted interviews in `/api/calendar`,
|
||||
their invitation through `/api/invitations`, see accepted interviews in `/api/schedule`,
|
||||
and can write notes only after the interview has started. Notes are private to
|
||||
authenticated attendees. The opaque `/participant/meeting/:candidateToken` view
|
||||
exposes only candidate-safe schedule and shared-document data.
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -38,7 +38,7 @@ dev:
|
|||
docker compose up --build
|
||||
|
||||
db-up:
|
||||
docker compose up -d db
|
||||
docker compose up -d db rabbitmq
|
||||
|
||||
db-down:
|
||||
docker compose down
|
||||
|
|
|
|||
|
|
@ -15,11 +15,20 @@ type InterviewNoteEvent struct {
|
|||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type InterviewAttendeeEvent struct {
|
||||
UserID uint `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type InterviewEvent struct {
|
||||
Type string `json:"type"`
|
||||
InterviewID uint `json:"interviewId"`
|
||||
Note *InterviewNoteEvent `json:"note,omitempty"`
|
||||
SharedDocument string `json:"sharedDocument,omitempty"`
|
||||
Type string `json:"type"`
|
||||
InterviewID uint `json:"interviewId"`
|
||||
Note *InterviewNoteEvent `json:"note,omitempty"`
|
||||
Attendee *InterviewAttendeeEvent `json:"attendee,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
SharedDocument string `json:"sharedDocument,omitempty"`
|
||||
}
|
||||
|
||||
type IEventBus interface {
|
||||
|
|
|
|||
|
|
@ -297,14 +297,14 @@ func TestCompleteInterviewWorkflow(t *testing.T) {
|
|||
}, http.StatusOK, &coLogin)
|
||||
|
||||
var inbox []webmodel.ScheduledInterviewResponse
|
||||
requestJSON(t, http.MethodGet, "/api/inbox", coLogin.Token, nil, http.StatusOK, &inbox)
|
||||
requestJSON(t, http.MethodGet, "/api/invitations", coLogin.Token, nil, http.StatusOK, &inbox)
|
||||
if len(inbox) != 1 || inbox[0].ID != scheduled.ID {
|
||||
t.Fatalf("scheduled interview missing from co-interviewer inbox: %#v", inbox)
|
||||
}
|
||||
requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/inbox/%d", scheduled.ID),
|
||||
requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/invitations/%d", scheduled.ID),
|
||||
coLogin.Token, map[string]any{"status": "accepted"}, http.StatusOK, &scheduled)
|
||||
var calendar []webmodel.ScheduledInterviewResponse
|
||||
requestJSON(t, http.MethodGet, "/api/calendar", coLogin.Token, nil, http.StatusOK, &calendar)
|
||||
requestJSON(t, http.MethodGet, "/api/schedule", coLogin.Token, nil, http.StatusOK, &calendar)
|
||||
if len(calendar) != 1 || calendar[0].ID != scheduled.ID {
|
||||
t.Fatalf("accepted interview missing from calendar: %#v", calendar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,7 +166,16 @@ func (s *ScheduledInterviewService) UpdateStatus(
|
|||
if !valid {
|
||||
return model.ScheduledInterview{}, fmt.Errorf("%w: invalid interview status transition", ErrInvalidState)
|
||||
}
|
||||
return s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status)
|
||||
updated, err := s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status)
|
||||
if err != nil {
|
||||
return model.ScheduledInterview{}, err
|
||||
}
|
||||
if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
|
||||
Type: "status.updated", InterviewID: interviewID, Status: updated.Status,
|
||||
}); err != nil {
|
||||
return model.ScheduledInterview{}, fmt.Errorf("publish persisted interview status: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) UpdateDocument(
|
||||
|
|
@ -243,10 +252,24 @@ func (s *ScheduledInterviewService) UpdateInbox(
|
|||
if status != "accepted" && status != "declined" {
|
||||
return model.ScheduledInterview{}, fmt.Errorf("%w: attendee status must be accepted or declined", ErrValidation)
|
||||
}
|
||||
if _, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status); err != nil {
|
||||
attendee, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status)
|
||||
if err != nil {
|
||||
return model.ScheduledInterview{}, err
|
||||
}
|
||||
return s.Get(ctx, userID, interviewID)
|
||||
updated, err := s.Get(ctx, userID, interviewID)
|
||||
if err != nil {
|
||||
return model.ScheduledInterview{}, err
|
||||
}
|
||||
if err := s.bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
|
||||
Type: "attendee.updated", InterviewID: interviewID,
|
||||
Attendee: &eventbus.InterviewAttendeeEvent{
|
||||
UserID: attendee.UserID, DisplayName: attendee.User.DisplayName,
|
||||
Email: attendee.User.Email, Status: attendee.Status,
|
||||
},
|
||||
}); err != nil {
|
||||
return model.ScheduledInterview{}, fmt.Errorf("publish persisted attendee status: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *ScheduledInterviewService) ListCalendar(
|
||||
|
|
@ -303,7 +326,7 @@ func (s *ScheduledInterviewService) subscribeToInterview(
|
|||
if event.InterviewID != interviewID {
|
||||
continue
|
||||
}
|
||||
if documentsOnly && event.Type != "document.updated" {
|
||||
if documentsOnly && event.Type != "document.updated" && event.Type != "status.updated" {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ type scheduledStore struct {
|
|||
createdIDs []uint
|
||||
status string
|
||||
document string
|
||||
attendee model.InterviewAttendee
|
||||
}
|
||||
|
||||
func (f *scheduledStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) {
|
||||
|
|
@ -68,6 +69,20 @@ func (f *scheduledStore) UpdateScheduledInterviewDocument(
|
|||
f.interview.SharedDocument = document
|
||||
return f.interview, nil
|
||||
}
|
||||
func (f *scheduledStore) UpdateAttendeeStatus(
|
||||
_ context.Context, _, userID uint, status string,
|
||||
) (model.InterviewAttendee, error) {
|
||||
f.attendee = model.InterviewAttendee{
|
||||
UserID: userID, Status: status,
|
||||
User: model.User{ID: userID, DisplayName: "Ada", Email: "ada@example.com"},
|
||||
}
|
||||
for index := range f.interview.Attendees {
|
||||
if f.interview.Attendees[index].UserID == userID {
|
||||
f.interview.Attendees[index] = f.attendee
|
||||
}
|
||||
}
|
||||
return f.attendee, nil
|
||||
}
|
||||
func (f *scheduledStore) CreateInterviewNote(
|
||||
_ context.Context, value model.InterviewNote,
|
||||
) (model.InterviewNote, error) {
|
||||
|
|
@ -414,6 +429,7 @@ func TestUpdateDocumentPublishesEvent(t *testing.T) {
|
|||
ID: 9, Status: "scheduled", Attendees: []model.InterviewAttendee{{UserID: 1}},
|
||||
},
|
||||
}
|
||||
|
||||
bus := eventbus.NewMemoryBus()
|
||||
events, err := bus.SubscribeInterviewEvents(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -431,6 +447,36 @@ func TestUpdateDocumentPublishesEvent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStatusAndAttendeeUpdatesPublishEvents(t *testing.T) {
|
||||
store := &scheduledStore{
|
||||
fakeStore: &fakeStore{},
|
||||
interview: model.ScheduledInterview{
|
||||
ID: 9, Status: "scheduled",
|
||||
Attendees: []model.InterviewAttendee{{UserID: 1, Status: "pending"}},
|
||||
},
|
||||
}
|
||||
bus := eventbus.NewMemoryBus()
|
||||
events, err := bus.SubscribeInterviewEvents(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
interviews := NewScheduledInterviewService(store, bus)
|
||||
if _, err := interviews.UpdateStatus(context.Background(), 1, 9, "started"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event := <-events; event.Type != "status.updated" || event.Status != "started" {
|
||||
t.Fatalf("unexpected status event: %#v", event)
|
||||
}
|
||||
store.interview.Status = "scheduled"
|
||||
if _, err := interviews.UpdateInbox(context.Background(), 1, 9, "accepted"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event := <-events; event.Type != "attendee.updated" || event.Attendee == nil ||
|
||||
event.Attendee.UserID != 1 || event.Attendee.Status != "accepted" {
|
||||
t.Fatalf("unexpected attendee event: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) {
|
||||
bus := eventbus.NewMemoryBus()
|
||||
interviews := NewScheduledInterviewService(&fakeStore{}, bus)
|
||||
|
|
@ -455,14 +501,27 @@ func TestParticipantSubscriptionFiltersPrivateNotes(t *testing.T) {
|
|||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bus.PublishInterviewEvent(ctx, eventbus.InterviewEvent{
|
||||
Type: "status.updated", InterviewID: 0, Status: "started",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type != "document.updated" || event.SharedDocument != "candidate-safe" {
|
||||
t.Fatalf("participant received unexpected event: %#v", event)
|
||||
t.Fatalf("participant received unexpected document event: %#v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("participant did not receive document event")
|
||||
}
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type != "status.updated" || event.Status != "started" {
|
||||
t.Fatalf("participant received unexpected event: %#v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("participant did not receive status event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCreationHashesPasswordAndUsesCoInterviewerRole(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
|||
protected.Get("/api/scheduled-interviews/{id}/events", h.InterviewEvents)
|
||||
protected.Get("/api/users", h.ListUsers)
|
||||
protected.Post("/api/users", h.CreateUser)
|
||||
protected.Get("/api/inbox", h.ListInbox)
|
||||
protected.Patch("/api/inbox/{id}", h.UpdateInbox)
|
||||
protected.Get("/api/calendar", h.ListCalendar)
|
||||
protected.Get("/api/invitations", h.ListInbox)
|
||||
protected.Patch("/api/invitations/{id}", h.UpdateInbox)
|
||||
protected.Get("/api/schedule", h.ListCalendar)
|
||||
protected.Get("/api/questions", h.ListQuestions)
|
||||
protected.Post("/api/questions", h.CreateQuestion)
|
||||
protected.Put("/api/questions/{id}", h.UpdateQuestion)
|
||||
|
|
|
|||
|
|
@ -75,11 +75,11 @@ Feature: Recruitment workspace
|
|||
|
||||
Scenario: User story - an interviewer can reach collaboration areas
|
||||
Given I am signed in as a valid interviewer
|
||||
When I follow the "Inbox" link
|
||||
Then I should see the heading "Inbox"
|
||||
And I should see "Inbox zero"
|
||||
When I follow the "Calendar" link
|
||||
Then I should be on the "/calendar" page
|
||||
When I follow the "Invitations" link
|
||||
Then I should see the heading "Invitations"
|
||||
And I should see "Invitation zero"
|
||||
When I follow the "Schedule" link
|
||||
Then I should be on the "/schedule" page
|
||||
And I should see "Schedule"
|
||||
When I follow the "Users" link
|
||||
Then I should see the heading "Interview team"
|
||||
|
|
|
|||
55
deploy/local.yaml
Normal file
55
deploy/local.yaml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Generated by Rancher
|
||||
# name: kubeconfig-ldfvp
|
||||
# createdTimestamp: 2026-09-20T09:13:04Z
|
||||
# ttl: 2592000
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: "rancher"
|
||||
cluster:
|
||||
server: "https://rancher.urpi.be"
|
||||
certificate-authority-data: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJ2VENDQ\
|
||||
VdPZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQkdNUnd3R2dZRFZRUUtFeE5rZVc1aGJXbGoKY\
|
||||
kdsemRHVnVaWEl0YjNKbk1TWXdKQVlEVlFRRERCMWtlVzVoYldsamJHbHpkR1Z1WlhJdFkyRkFNV\
|
||||
GM0T1RnNQpNVGczTURBZUZ3MHlOakE1TWpBd056RXhNVEJhRncwek5qQTVNVGN3TnpFeE1UQmFNR\
|
||||
Vl4SERBYUJnTlZCQW9UCkUyUjVibUZ0YVdOc2FYTjBaVzVsY2kxdmNtY3hKakFrQmdOVkJBTU1IV\
|
||||
1I1Ym1GdGFXTnNhWE4wWlc1bGNpMWoKWVVBeE56ZzVPRGt4T0Rjd01Ga3dFd1lIS29aSXpqMENBU\
|
||||
VlJS29aSXpqMERBUWNEUWdBRXNIVEFSdUJJcjNZQQo0NlVzRmM5N1lkRHd1eGtnNHNSU21ML3ZCS\
|
||||
Th3dWh5Z2M4Wm1jVUI4VWhWbUZvdEd4UnFqR3dWNG1GYXlxdGdQCmNnQVZmdkJ4bUtOQ01FQXdEZ\
|
||||
1lEVlIwUEFRSC9CQVFEQWdLa01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0hRWUQKVlIwT0JCWUVGT\
|
||||
kNyVndRcjFXeWFpdnJMS1BLZ0hmbUVjRUFyTUFvR0NDcUdTTTQ5QkFNQ0EwZ0FNRVVDSVFERAorU\
|
||||
2lMaXdOODFNU2FEOWEzaStUNkJGOHVjdXNKQm5xM2pJdHV4WGpFMlFJZ1Z5L3FNaEtRSFRIa3pzV\
|
||||
jIwWThBCll5SWdTWWZ1czZkaFdwNEI5TFl2eEVzPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t"
|
||||
- name: "local"
|
||||
cluster:
|
||||
server: "https://rancher.urpi.be/k8s/clusters/local"
|
||||
certificate-authority-data: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJ2VENDQ\
|
||||
VdPZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQkdNUnd3R2dZRFZRUUtFeE5rZVc1aGJXbGoKY\
|
||||
kdsemRHVnVaWEl0YjNKbk1TWXdKQVlEVlFRRERCMWtlVzVoYldsamJHbHpkR1Z1WlhJdFkyRkFNV\
|
||||
GM0T1RnNQpNVGczTURBZUZ3MHlOakE1TWpBd056RXhNVEJhRncwek5qQTVNVGN3TnpFeE1UQmFNR\
|
||||
Vl4SERBYUJnTlZCQW9UCkUyUjVibUZ0YVdOc2FYTjBaVzVsY2kxdmNtY3hKakFrQmdOVkJBTU1IV\
|
||||
1I1Ym1GdGFXTnNhWE4wWlc1bGNpMWoKWVVBeE56ZzVPRGt4T0Rjd01Ga3dFd1lIS29aSXpqMENBU\
|
||||
VlJS29aSXpqMERBUWNEUWdBRXNIVEFSdUJJcjNZQQo0NlVzRmM5N1lkRHd1eGtnNHNSU21ML3ZCS\
|
||||
Th3dWh5Z2M4Wm1jVUI4VWhWbUZvdEd4UnFqR3dWNG1GYXlxdGdQCmNnQVZmdkJ4bUtOQ01FQXdEZ\
|
||||
1lEVlIwUEFRSC9CQVFEQWdLa01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0hRWUQKVlIwT0JCWUVGT\
|
||||
kNyVndRcjFXeWFpdnJMS1BLZ0hmbUVjRUFyTUFvR0NDcUdTTTQ5QkFNQ0EwZ0FNRVVDSVFERAorU\
|
||||
2lMaXdOODFNU2FEOWEzaStUNkJGOHVjdXNKQm5xM2pJdHV4WGpFMlFJZ1Z5L3FNaEtRSFRIa3pzV\
|
||||
jIwWThBCll5SWdTWWZ1czZkaFdwNEI5TFl2eEVzPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t"
|
||||
|
||||
users:
|
||||
- name: "rancher"
|
||||
user:
|
||||
token: "ext/token-9fzp2:d729wqhv4gbjhd9qjghs74f7h6wqcmml9c79stw8bw5nj2w9p752nw"
|
||||
|
||||
|
||||
contexts:
|
||||
- name: "rancher"
|
||||
context:
|
||||
user: "rancher"
|
||||
cluster: "rancher"
|
||||
- name: "local"
|
||||
context:
|
||||
user: "rancher"
|
||||
cluster: "local"
|
||||
|
||||
current-context: "local"
|
||||
|
|
@ -67,8 +67,8 @@ Supported routes:
|
|||
- `PUT /api/interviews/:token/answers/:questionId`
|
||||
- `POST /api/interviews/:token/finish`
|
||||
- `GET|POST /api/users`
|
||||
- `GET /api/inbox` and `PATCH /api/inbox/:interviewId`
|
||||
- `GET /api/calendar`
|
||||
- `GET /api/invitations` and `PATCH /api/invitations/:interviewId`
|
||||
- `GET /api/schedule`
|
||||
- `GET /api/scheduled-interviews/:id`
|
||||
- `PATCH /api/scheduled-interviews/:id/status`
|
||||
- `PATCH /api/scheduled-interviews/:id/document`
|
||||
|
|
@ -98,9 +98,9 @@ form for the date, location, co-interviewers, and candidate-visible shared docum
|
|||
The authenticated workspace also includes:
|
||||
|
||||
- **Users** for listing and creating co-interviewers.
|
||||
- **Inbox** in the top-right account navigation for accepting or declining assignments.
|
||||
- **Invitations** in the top-right account navigation for accepting or declining assignments.
|
||||
- **Profile** in the top-right account navigation for the logged-in user's identity and role.
|
||||
- **Calendar** for interviews grouped by date.
|
||||
- **Schedule** for interviews grouped by date.
|
||||
- **Interviewer sessions** for changing status, editing prominent shared documentation, and
|
||||
posting private notes while an interview is started. Notes and shared-document changes arrive
|
||||
live through an authenticated streaming `fetch`; the app intentionally does not use
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api, authStorage, SESSION_EXPIRED_EVENT } from './client'
|
||||
import { subscribeToDomainEvent } from './domainEvents'
|
||||
|
||||
describe('API client', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -82,4 +83,62 @@ describe('API client', () => {
|
|||
const form = options.body as FormData
|
||||
expect([...form.keys()]).toEqual(['firstName', 'lastName', 'birthday', 'email', 'contactInfo'])
|
||||
})
|
||||
|
||||
it('publishes an entity event after a successful mutation', async () => {
|
||||
const job = {
|
||||
id: 7,
|
||||
title: 'Backend engineer',
|
||||
description: 'Build APIs',
|
||||
seniority: 'senior',
|
||||
position: 'backend',
|
||||
labels: [],
|
||||
requiredSkills: ['Go'],
|
||||
additionalSkills: [],
|
||||
status: 'published',
|
||||
durationMinutes: 60,
|
||||
questions: [],
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
} as const
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(job), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeToDomainEvent(`jobs/${job.id}`, listener)
|
||||
|
||||
await api.jobs.update(job.id, { status: 'published', durationMinutes: 60 })
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
resource: 'jobs/7',
|
||||
type: 'job.updated',
|
||||
data: job,
|
||||
})
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('does not publish an event when a mutation fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: 'invalid transition' }), {
|
||||
status: 409,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeToDomainEvent('scheduled-interviews/80', listener)
|
||||
|
||||
await expect(api.scheduledInterviews.setStatus(80, 'started')).rejects.toMatchObject({
|
||||
status: 409,
|
||||
})
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
User,
|
||||
InterviewNote,
|
||||
} from '../types'
|
||||
import { publishDomainEvent } from './domainEvents'
|
||||
|
||||
const TOKEN_KEY = 'go-guess-token'
|
||||
const USER_KEY = 'go-guess-user'
|
||||
|
|
@ -115,9 +116,20 @@ async function requestBlob(path: string): Promise<Blob> {
|
|||
if (response.status === 401) invalidateSession()
|
||||
throw new ApiError(`Request failed with status ${response.status}`, response.status)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
async function mutation<T>(
|
||||
response: Promise<T>,
|
||||
type: string,
|
||||
resources: (data: T) => string[],
|
||||
): Promise<T> {
|
||||
const data = await response
|
||||
publishDomainEvent(resources(data), type, data)
|
||||
return data
|
||||
}
|
||||
|
||||
async function subscribeToInterviewEvents(
|
||||
path: string,
|
||||
onEvent: (event: InterviewEvent) => void,
|
||||
|
|
@ -181,18 +193,34 @@ export const api = {
|
|||
list: () => request<Job[]>('/jobs'),
|
||||
get: (id: number) => request<Job>(`/jobs/${id}`),
|
||||
create: (input: CreateJobInput) =>
|
||||
request<Job>('/jobs', { method: 'POST', body: JSON.stringify(input) }),
|
||||
mutation(
|
||||
request<Job>('/jobs', { method: 'POST', body: JSON.stringify(input) }),
|
||||
'job.created',
|
||||
(job) => ['jobs', `jobs/${job.id}`],
|
||||
),
|
||||
update: (jobId: number, input: { status: JobStatus; durationMinutes: number }) =>
|
||||
request<Job>(`/jobs/${jobId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
mutation(
|
||||
request<Job>(`/jobs/${jobId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
'job.updated',
|
||||
() => ['jobs', `jobs/${jobId}`],
|
||||
),
|
||||
attachQuestion: (jobId: number, questionId: number) =>
|
||||
request<void>(`/jobs/${jobId}/questions/${questionId}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
mutation(
|
||||
request<void>(`/jobs/${jobId}/questions/${questionId}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
'job.question-attached',
|
||||
() => ['jobs', `jobs/${jobId}`, `questions/${questionId}`],
|
||||
),
|
||||
detachQuestion: (jobId: number, questionId: number) =>
|
||||
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
|
||||
mutation(
|
||||
request<void>(`/jobs/${jobId}/questions/${questionId}`, { method: 'DELETE' }),
|
||||
'job.question-detached',
|
||||
() => ['jobs', `jobs/${jobId}`, `questions/${questionId}`],
|
||||
),
|
||||
candidates: async (jobId: number) => {
|
||||
const matches = await request<CandidateMatch[]>(`/jobs/${jobId}/candidates`)
|
||||
return matches.map((match) => ({
|
||||
|
|
@ -205,50 +233,100 @@ export const api = {
|
|||
invitation: (jobId: number, invitationId: number) =>
|
||||
request<Interview>(`/jobs/${jobId}/invitations/${invitationId}`),
|
||||
invite: (jobId: number, participantId: number) =>
|
||||
request<Invitation>(`/jobs/${jobId}/invitations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ participantId }),
|
||||
}),
|
||||
mutation(
|
||||
request<Invitation>(`/jobs/${jobId}/invitations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ participantId }),
|
||||
}),
|
||||
'invitation.created',
|
||||
(invitation) => [
|
||||
`jobs/${jobId}`,
|
||||
`job-invitations/${jobId}`,
|
||||
`assessment-invitations/${invitation.id}`,
|
||||
],
|
||||
),
|
||||
setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) =>
|
||||
request<Invitation>(`/jobs/${jobId}/invitations/${invitationId}/outcome`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ outcome }),
|
||||
}),
|
||||
mutation(
|
||||
request<Invitation>(`/jobs/${jobId}/invitations/${invitationId}/outcome`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ outcome }),
|
||||
}),
|
||||
'invitation.outcome-updated',
|
||||
() => [
|
||||
`jobs/${jobId}`,
|
||||
`job-invitations/${jobId}`,
|
||||
`assessment-invitations/${invitationId}`,
|
||||
],
|
||||
),
|
||||
interviews: (jobId: number) => request<ScheduledInterview[]>(`/jobs/${jobId}/interviews`),
|
||||
scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) =>
|
||||
request<ScheduledInterview>(`/jobs/${jobId}/interviews`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
mutation(
|
||||
request<ScheduledInterview>(`/jobs/${jobId}/interviews`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
'scheduled-interview.created',
|
||||
(interview) => [
|
||||
`jobs/${jobId}`,
|
||||
'scheduled-interviews',
|
||||
`scheduled-interviews/${interview.id}`,
|
||||
'invitations',
|
||||
'schedule',
|
||||
],
|
||||
),
|
||||
},
|
||||
questions: {
|
||||
list: (search = '') =>
|
||||
request<Question[]>(`/questions${search ? `?search=${encodeURIComponent(search)}` : ''}`),
|
||||
create: (input: CreateQuestionInput) =>
|
||||
request<Question>('/questions', { method: 'POST', body: JSON.stringify(input) }),
|
||||
mutation(
|
||||
request<Question>('/questions', { method: 'POST', body: JSON.stringify(input) }),
|
||||
'question.created',
|
||||
(question) => ['questions', `questions/${question.id}`],
|
||||
),
|
||||
update: (id: number, input: CreateQuestionInput) =>
|
||||
request<Question>(`/questions/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
mutation(
|
||||
request<Question>(`/questions/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
'question.updated',
|
||||
() => ['questions', `questions/${id}`],
|
||||
),
|
||||
setDeprecated: (id: number, deprecated: boolean) =>
|
||||
request<Question>(`/questions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ deprecated }),
|
||||
}),
|
||||
mutation(
|
||||
request<Question>(`/questions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ deprecated }),
|
||||
}),
|
||||
'question.deprecation-updated',
|
||||
() => ['questions', `questions/${id}`],
|
||||
),
|
||||
},
|
||||
interviews: {
|
||||
get: (token: string) => request<Interview>(`/interviews/${token}`, {}, false),
|
||||
accept: (token: string) =>
|
||||
request<Interview>(`/interviews/${token}/accept`, { method: 'POST' }, false),
|
||||
mutation(
|
||||
request<Interview>(`/interviews/${token}/accept`, { method: 'POST' }, false),
|
||||
'assessment-invitation.accepted',
|
||||
(interview) => [`interviews/${token}`, `assessment-invitations/${interview.invitation.id}`],
|
||||
),
|
||||
answer: (token: string, questionId: number, answer: string) =>
|
||||
request<void>(
|
||||
`/interviews/${token}/answers/${questionId}`,
|
||||
{ method: 'PUT', body: JSON.stringify({ answer }) },
|
||||
false,
|
||||
mutation(
|
||||
request<void>(
|
||||
`/interviews/${token}/answers/${questionId}`,
|
||||
{ method: 'PUT', body: JSON.stringify({ answer }) },
|
||||
false,
|
||||
),
|
||||
'assessment-answer.saved',
|
||||
() => [`interviews/${token}`],
|
||||
),
|
||||
finish: (token: string) =>
|
||||
request<Interview>(`/interviews/${token}/finish`, { method: 'POST' }, false),
|
||||
mutation(
|
||||
request<Interview>(`/interviews/${token}/finish`, { method: 'POST' }, false),
|
||||
'assessment-invitation.completed',
|
||||
(interview) => [`interviews/${token}`, `assessment-invitations/${interview.invitation.id}`],
|
||||
),
|
||||
},
|
||||
participants: {
|
||||
list: () => request<Participant[]>('/participants'),
|
||||
|
|
@ -264,41 +342,70 @@ export const api = {
|
|||
form.append('contactInfo', input.contactInfo)
|
||||
if (input.photo) form.append('photo', input.photo)
|
||||
if (input.cv) form.append('cv', input.cv)
|
||||
return request<Participant>('/participants', { method: 'POST', body: form })
|
||||
return mutation(
|
||||
request<Participant>('/participants', { method: 'POST', body: form }),
|
||||
'participant.created',
|
||||
(participant) => ['participants', `participants/${participant.id}`],
|
||||
)
|
||||
},
|
||||
},
|
||||
users: {
|
||||
list: () => request<User[]>('/users'),
|
||||
create: (input: { email: string; password: string; displayName: string }) =>
|
||||
request<User>('/users', { method: 'POST', body: JSON.stringify(input) }),
|
||||
mutation(
|
||||
request<User>('/users', { method: 'POST', body: JSON.stringify(input) }),
|
||||
'user.created',
|
||||
(user) => ['users', `users/${user.id}`],
|
||||
),
|
||||
},
|
||||
inbox: {
|
||||
list: () => request<ScheduledInterview[]>('/inbox'),
|
||||
list: () => request<ScheduledInterview[]>('/invitations'),
|
||||
respond: (interviewId: number, status: 'accepted' | 'declined') =>
|
||||
request<ScheduledInterview>(`/inbox/${interviewId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
mutation(
|
||||
request<ScheduledInterview>(`/invitations/${interviewId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
'interview-invitation.responded',
|
||||
() => [
|
||||
'invitations',
|
||||
`invitations/${interviewId}`,
|
||||
'schedule',
|
||||
`scheduled-interviews/${interviewId}`,
|
||||
],
|
||||
),
|
||||
},
|
||||
calendar: () => request<ScheduledInterview[]>('/calendar'),
|
||||
calendar: () => request<ScheduledInterview[]>('/schedule'),
|
||||
scheduledInterviews: {
|
||||
get: (id: number) => request<ScheduledInterview>(`/scheduled-interviews/${id}`),
|
||||
setStatus: (id: number, status: string) =>
|
||||
request<ScheduledInterview>(`/scheduled-interviews/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
mutation(
|
||||
request<ScheduledInterview>(`/scheduled-interviews/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
'scheduled-interview.status-updated',
|
||||
() => ['scheduled-interviews', `scheduled-interviews/${id}`, 'schedule', 'invitations'],
|
||||
),
|
||||
updateDocument: (id: number, sharedDocument: string) =>
|
||||
request<ScheduledInterview>(`/scheduled-interviews/${id}/document`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sharedDocument }),
|
||||
}),
|
||||
mutation(
|
||||
request<ScheduledInterview>(`/scheduled-interviews/${id}/document`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sharedDocument }),
|
||||
}),
|
||||
'scheduled-interview.document-updated',
|
||||
() => [`scheduled-interviews/${id}`],
|
||||
),
|
||||
notes: (id: number) => request<InterviewNote[]>(`/scheduled-interviews/${id}/notes`),
|
||||
addNote: (id: number, body: string) =>
|
||||
request<InterviewNote>(`/scheduled-interviews/${id}/notes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
}),
|
||||
mutation(
|
||||
request<InterviewNote>(`/scheduled-interviews/${id}/notes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
}),
|
||||
'scheduled-interview.note-created',
|
||||
() => [`scheduled-interviews/${id}/notes`],
|
||||
),
|
||||
subscribe: (
|
||||
id: number,
|
||||
onEvent: (event: InterviewEvent) => void,
|
||||
|
|
|
|||
32
frontend/src/api/domainEvents.test.ts
Normal file
32
frontend/src/api/domainEvents.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { publishDomainEvent, subscribeToDomainEvent } from './domainEvents'
|
||||
|
||||
describe('domain events', () => {
|
||||
it('delivers an event only to its resource subscribers', () => {
|
||||
const jobListener = vi.fn()
|
||||
const otherListener = vi.fn()
|
||||
const unsubscribeJob = subscribeToDomainEvent('jobs/7', jobListener)
|
||||
const unsubscribeOther = subscribeToDomainEvent('jobs/8', otherListener)
|
||||
|
||||
publishDomainEvent(['jobs', 'jobs/7'], 'job.updated', { id: 7 })
|
||||
|
||||
expect(jobListener).toHaveBeenCalledWith({
|
||||
resource: 'jobs/7',
|
||||
type: 'job.updated',
|
||||
data: { id: 7 },
|
||||
})
|
||||
expect(otherListener).not.toHaveBeenCalled()
|
||||
unsubscribeJob()
|
||||
unsubscribeOther()
|
||||
})
|
||||
|
||||
it('stops delivering events after unsubscribe', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeToDomainEvent('questions/2', listener)
|
||||
unsubscribe()
|
||||
|
||||
publishDomainEvent(['questions/2'], 'question.updated', { id: 2 })
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
29
frontend/src/api/domainEvents.ts
Normal file
29
frontend/src/api/domainEvents.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export interface DomainEvent<T = unknown> {
|
||||
type: string
|
||||
resource: string
|
||||
data: T
|
||||
}
|
||||
|
||||
type DomainEventListener<T> = (event: DomainEvent<T>) => void
|
||||
|
||||
const target = new EventTarget()
|
||||
|
||||
export function publishDomainEvent<T>(resources: string[], type: string, data: T) {
|
||||
for (const resource of resources) {
|
||||
target.dispatchEvent(
|
||||
new CustomEvent(resource, {
|
||||
detail: { type, resource, data } satisfies DomainEvent<T>,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeToDomainEvent<T>(
|
||||
resource: string,
|
||||
listener: DomainEventListener<T>,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const handle = (event: Event) => listener((event as CustomEvent<DomainEvent<T>>).detail)
|
||||
target.addEventListener(resource, handle, { signal })
|
||||
return () => target.removeEventListener(resource, handle)
|
||||
}
|
||||
|
|
@ -224,7 +224,7 @@ describe('Go Guess frontend', () => {
|
|||
}),
|
||||
)
|
||||
|
||||
renderApp('/calendar')
|
||||
renderApp('/schedule')
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Sign in to your workspace' }),
|
||||
|
|
@ -251,9 +251,9 @@ describe('Go Guess frontend', () => {
|
|||
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
|
||||
|
||||
const accountNavigation = screen.getByRole('navigation', { name: 'Account navigation' })
|
||||
expect(within(accountNavigation).getByRole('link', { name: 'Inbox' })).toHaveAttribute(
|
||||
expect(within(accountNavigation).getByRole('link', { name: 'Invitations' })).toHaveAttribute(
|
||||
'href',
|
||||
'/inbox',
|
||||
'/invitations',
|
||||
)
|
||||
expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute(
|
||||
'href',
|
||||
|
|
@ -261,7 +261,9 @@ describe('Go Guess frontend', () => {
|
|||
)
|
||||
|
||||
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' })
|
||||
expect(within(primaryNavigation).queryByRole('link', { name: 'Inbox' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(primaryNavigation).queryByRole('link', { name: 'Invitations' }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
const footerNavigation = screen.getByRole('navigation', { name: 'Footer navigation' })
|
||||
expect(within(footerNavigation).getByRole('link', { name: 'Jobs' })).toHaveAttribute(
|
||||
|
|
@ -407,11 +409,24 @@ describe('Go Guess frontend', () => {
|
|||
it('attaches and detaches library questions without deleting them', async () => {
|
||||
authenticate()
|
||||
const user = userEvent.setup()
|
||||
let attached = false
|
||||
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
||||
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
||||
const path = String(input)
|
||||
const method = options?.method ?? 'GET'
|
||||
if (path === '/api/jobs/7/questions/11') {
|
||||
attached = method !== 'DELETE'
|
||||
return response({}, method === 'DELETE' ? 200 : 201)
|
||||
}
|
||||
if (path === '/api/jobs/7') return response({ ...job, questions: attached ? [question] : [] })
|
||||
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
|
||||
return defaultFetch(input, options)
|
||||
})
|
||||
renderApp('/jobs/7')
|
||||
|
||||
await user.click(await screen.findByRole('tab', { name: /questions/i }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Attach' }))
|
||||
await user.click(screen.getByRole('button', { name: `Detach ${question.text}` }))
|
||||
await user.click(await screen.findByRole('button', { name: `Detach ${question.text}` }))
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/jobs/7/questions/11',
|
||||
|
|
@ -838,7 +853,7 @@ describe('Go Guess frontend', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('accepts and declines interviews from the inbox', async () => {
|
||||
it('updates an invitation card through its entity-scoped event', async () => {
|
||||
authenticate()
|
||||
const user = userEvent.setup()
|
||||
const scheduled = {
|
||||
|
|
@ -853,33 +868,48 @@ describe('Go Guess frontend', () => {
|
|||
candidateToken: 'meeting-token',
|
||||
candidateUrl: '/participant/meeting/meeting-token',
|
||||
sharedDocument: 'Agenda',
|
||||
attendees: [],
|
||||
attendees: [
|
||||
{
|
||||
userId: 1,
|
||||
displayName: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
notes: [],
|
||||
}
|
||||
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
||||
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
||||
const path = String(input)
|
||||
if (path === '/api/inbox' && !options?.method) return response([scheduled])
|
||||
if (path === '/api/inbox/80')
|
||||
return response({ ...scheduled, status: JSON.parse(String(options?.body)).status })
|
||||
if (path === '/api/invitations' && !options?.method) return response([scheduled])
|
||||
if (path === '/api/invitations/80')
|
||||
return response({
|
||||
...scheduled,
|
||||
attendees: [
|
||||
{
|
||||
...scheduled.attendees[0],
|
||||
status: JSON.parse(String(options?.body)).status,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (!defaultFetch) throw new Error(`Unexpected request: ${path}`)
|
||||
return defaultFetch(input, options)
|
||||
})
|
||||
renderApp('/inbox')
|
||||
renderApp('/invitations')
|
||||
await user.click(await screen.findByRole('button', { name: 'Accept' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Decline' }))
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/inbox/80',
|
||||
'/api/invitations/80',
|
||||
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ status: 'accepted' }) }),
|
||||
)
|
||||
expect(screen.getByText('declined')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('link', { name: 'Open session' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Accept' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('groups calendar interviews by date and links to the session', async () => {
|
||||
authenticate()
|
||||
const defaultFetch = vi.mocked(fetch).getMockImplementation()
|
||||
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => {
|
||||
if (String(input) === '/api/calendar')
|
||||
if (String(input) === '/api/schedule')
|
||||
return response([
|
||||
{
|
||||
id: 80,
|
||||
|
|
@ -900,7 +930,7 @@ describe('Go Guess frontend', () => {
|
|||
if (!defaultFetch) throw new Error(`Unexpected request: ${String(input)}`)
|
||||
return defaultFetch(input, options)
|
||||
})
|
||||
renderApp('/calendar')
|
||||
renderApp('/schedule')
|
||||
expect(await screen.findByText('Amélie Dubois')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Open session' })).toHaveAttribute(
|
||||
'href',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { GoGuessHeader } from '../components/layout/GoGuessHeader'
|
|||
import { WorkspaceUtilities } from '../components/layout/WorkspaceUtilities'
|
||||
import { NotFound } from '../components/ui/NotFound'
|
||||
import { DashboardPage } from '../features/dashboard/DashboardPage'
|
||||
import { InboxPage, ScheduledInterviewPage } from '../features/interviews'
|
||||
import { InvitationPage, ScheduledInterviewPage } from '../features/interviews'
|
||||
import { CreateJobPage, JobDetailPage, JobsPage } from '../features/jobs'
|
||||
import {
|
||||
CreateParticipantPage,
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
ParticipantsPage,
|
||||
} from '../features/participants'
|
||||
import { QuestionLibraryPage, QuestionPage } from '../features/questions'
|
||||
import { CalendarPage, ProfilePage, UsersPage } from '../features/users'
|
||||
import { SchedulePage, ProfilePage, UsersPage } from '../features/users'
|
||||
|
||||
export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -37,8 +37,8 @@ export function AuthenticatedApp({ onLogout }: { onLogout: () => void }) {
|
|||
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/invitations" element={<InvitationPage />} />
|
||||
<Route path="/schedule" element={<SchedulePage />} />
|
||||
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { api } from '../api/client'
|
|||
import { errorMessage } from '../components/actions'
|
||||
import type { CreateJobInput, CreateParticipantInput, Job, Participant } from '../types'
|
||||
import { DataContext } from './dataState'
|
||||
import { useDomainEvent } from './useDomainEvent'
|
||||
|
||||
export function DataProvider({ children }: { children: ReactNode }) {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
|
|
@ -10,8 +11,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function reload() {
|
||||
setLoading(true)
|
||||
async function refresh(showLoading: boolean) {
|
||||
if (showLoading) setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [jobItems, participantItems] = await Promise.all([
|
||||
|
|
@ -23,10 +24,14 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (showLoading) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await refresh(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void Promise.all([api.jobs.list(), api.participants.list()])
|
||||
|
|
@ -46,16 +51,28 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
useDomainEvent<Job | undefined>('jobs', (event) => {
|
||||
const job = event.data
|
||||
if (job && (event.type === 'job.created' || event.type === 'job.updated')) {
|
||||
setJobs((current) => [job, ...current.filter((currentJob) => currentJob.id !== job.id)])
|
||||
return
|
||||
}
|
||||
void refresh(false)
|
||||
})
|
||||
|
||||
useDomainEvent<Participant>('participants', (event) => {
|
||||
setParticipants((current) => [
|
||||
event.data,
|
||||
...current.filter((participant) => participant.id !== event.data.id),
|
||||
])
|
||||
})
|
||||
|
||||
async function createJob(input: CreateJobInput) {
|
||||
const created = await api.jobs.create(input)
|
||||
setJobs((current) => [created, ...current])
|
||||
return created
|
||||
return api.jobs.create(input)
|
||||
}
|
||||
|
||||
async function createParticipant(input: CreateParticipantInput) {
|
||||
const created = await api.participants.create(input)
|
||||
setParticipants((current) => [created, ...current])
|
||||
return created
|
||||
return api.participants.create(input)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
16
frontend/src/app/useDomainEvent.ts
Normal file
16
frontend/src/app/useDomainEvent.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { type DomainEvent, subscribeToDomainEvent } from '../api/domainEvents'
|
||||
|
||||
export function useDomainEvent<T>(resource: string, listener: (event: DomainEvent<T>) => void) {
|
||||
const listenerRef = useRef(listener)
|
||||
|
||||
useEffect(() => {
|
||||
listenerRef.current = listener
|
||||
}, [listener])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
subscribeToDomainEvent<T>(resource, (event) => listenerRef.current(event), controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [resource])
|
||||
}
|
||||
|
|
@ -8,6 +8,6 @@ export function errorMessage(error: unknown) {
|
|||
|
||||
export function message(error: unknown) {
|
||||
return error instanceof ApiError || error instanceof Error
|
||||
? error.message
|
||||
: 'Something went wrong'
|
||||
? error.message
|
||||
: 'Something went wrong'
|
||||
}
|
||||
|
|
@ -6,13 +6,13 @@ export function WorkspaceUtilities({ user }: { user: User | null }) {
|
|||
const profileLabel = user?.displayName || user?.email || 'Profile'
|
||||
return (
|
||||
<nav className="workspace-utilities" aria-label="Account navigation">
|
||||
<NavLink className="utility-link" to="/inbox">
|
||||
<NavLink className="utility-link" to="/invitations">
|
||||
<Mail size={18} aria-hidden="true" />
|
||||
<span>Inbox</span>
|
||||
<span>Invitations</span>
|
||||
</NavLink>
|
||||
<NavLink className="utility-link" to="/calendar">
|
||||
<NavLink className="utility-link" to="/schedule">
|
||||
<CalendarDays size={18} aria-hidden="true" />
|
||||
<span>Calendar</span>
|
||||
<span>Schedule</span>
|
||||
</NavLink>
|
||||
<NavLink className="utility-link profile-utility" to="/profile">
|
||||
<span className="avatar tiny" aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ export const footerLinkGroups: FooterLinkGroup[] = [
|
|||
{
|
||||
title: 'Useful links',
|
||||
links: [
|
||||
{ label: 'Your inbox', href: '/inbox' },
|
||||
{ label: 'Calendar', href: '/calendar' },
|
||||
{ label: 'Invitations', href: '/invitations' },
|
||||
{ label: 'Schedule', href: '/schedule' },
|
||||
{ label: 'Your profile', href: '/profile' },
|
||||
{ label: 'Interview team', href: '/users' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ArrowLeft, ArrowRight, Check, CircleHelp, Clock3 } from 'lucide-react'
|
|||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
import { useDomainEvent } from '../../app/useDomainEvent'
|
||||
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
|
||||
import Logo from '../../components/layout/Logo'
|
||||
import type { Interview } from '../../types'
|
||||
|
|
@ -40,6 +41,16 @@ export function ParticipantInterviewPage() {
|
|||
}
|
||||
}, [token])
|
||||
|
||||
useDomainEvent<Interview | undefined>(`interviews/${token}`, (event) => {
|
||||
if (
|
||||
!event.data ||
|
||||
!['assessment-invitation.accepted', 'assessment-invitation.completed'].includes(event.type)
|
||||
)
|
||||
return
|
||||
setInterview(event.data)
|
||||
setAnswers(event.data.answers ?? {})
|
||||
})
|
||||
|
||||
const acceptedAt = interview?.invitation.acceptedAt
|
||||
const durationMinutes = interview?.job.durationMinutes
|
||||
useEffect(() => {
|
||||
|
|
@ -57,9 +68,7 @@ export function ParticipantInterviewPage() {
|
|||
setWorking(true)
|
||||
setError('')
|
||||
try {
|
||||
const accepted = await api.interviews.accept(token)
|
||||
setInterview(accepted)
|
||||
setAnswers(accepted.answers ?? {})
|
||||
await api.interviews.accept(token)
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
} finally {
|
||||
|
|
@ -107,7 +116,7 @@ export function ParticipantInterviewPage() {
|
|||
setError('')
|
||||
try {
|
||||
await persistCurrent()
|
||||
setInterview(await api.interviews.finish(token))
|
||||
await api.interviews.finish(token)
|
||||
} catch (requestError) {
|
||||
finishing.current = false
|
||||
setError(errorMessage(requestError))
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ export function DashboardPage() {
|
|||
</span>
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
<Link to="/calendar">
|
||||
<Link to="/schedule">
|
||||
<Sparkles size={20} />
|
||||
<span>
|
||||
<strong>Review the calendar</strong>
|
||||
|
|
|
|||
53
frontend/src/features/interviews/InvitationPage.tsx
Normal file
53
frontend/src/features/interviews/InvitationPage.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../api/client'
|
||||
import { useDomainEvent } from '../../app/useDomainEvent'
|
||||
import { errorMessage } from '../../components/actions'
|
||||
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
|
||||
import type { ScheduledInterview } from '../../types'
|
||||
import { InvitationCard } from './components/InvitationCard'
|
||||
|
||||
export function InvitationPage() {
|
||||
const [items, setItems] = useState<ScheduledInterview[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
void api.inbox
|
||||
.list()
|
||||
.then(setItems)
|
||||
.catch((value) => setError(errorMessage(value)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useDomainEvent<ScheduledInterview>('invitations', (event) => {
|
||||
setItems((current) => current.map((item) => (item.id === event.data.id ? event.data : item)))
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Interview team</span>
|
||||
<h1>Invitations</h1>
|
||||
<p>Respond to interview invitations assigned to you.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error} />}
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<section className="meeting-list">
|
||||
{items.map((item) => (
|
||||
<InvitationCard invitation={item} onError={setError} key={item.id} />
|
||||
))}
|
||||
{!items.length && (
|
||||
<div className="card empty-state">
|
||||
<h3>Invitation zero</h3>
|
||||
<p>You have no interview invitations.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
98
frontend/src/features/interviews/ParticipantMeetingPage.tsx
Normal file
98
frontend/src/features/interviews/ParticipantMeetingPage.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
import { errorMessage } from '../../components/actions'
|
||||
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
|
||||
import type { ParticipantMeeting } from '../../types'
|
||||
import { DateAndPlace } from './components/InterviewCard'
|
||||
|
||||
export function ParticipantMeetingPage() {
|
||||
const token = useParams().token ?? ''
|
||||
const [meeting, setMeeting] = useState<ParticipantMeeting>()
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let latestDocument: string | undefined
|
||||
let latestStatus: string | undefined
|
||||
const controller = new AbortController()
|
||||
void api.participantMeetings
|
||||
.subscribe(
|
||||
token,
|
||||
(event) => {
|
||||
if (event.type === 'document.updated') latestDocument = event.sharedDocument
|
||||
if (event.type === 'status.updated') latestStatus = event.status
|
||||
setMeeting((current) => {
|
||||
if (!current) return current
|
||||
if (event.type === 'document.updated') {
|
||||
return { ...current, sharedDocument: event.sharedDocument }
|
||||
}
|
||||
if (event.type === 'status.updated') return { ...current, status: event.status }
|
||||
return current
|
||||
})
|
||||
},
|
||||
controller.signal,
|
||||
(value) => {
|
||||
if (active) setError(errorMessage(value))
|
||||
},
|
||||
)
|
||||
.catch((value) => {
|
||||
if (active && !controller.signal.aborted) setError(errorMessage(value))
|
||||
})
|
||||
void api.participantMeetings
|
||||
.get(token)
|
||||
.then((value) => {
|
||||
if (active) {
|
||||
setMeeting({
|
||||
...value,
|
||||
status: latestStatus ?? value.status,
|
||||
sharedDocument: latestDocument ?? value.sharedDocument,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((value) => {
|
||||
if (active) setError(errorMessage(value))
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
}
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<main className="public-interview">
|
||||
<header>
|
||||
<Link className="logo" to="/" aria-label="Go Guess home">
|
||||
<span className="logo-mark">G</span>
|
||||
<span>
|
||||
<strong>GO GUESS</strong>
|
||||
<small>Talent workspace</small>
|
||||
</span>
|
||||
</Link>
|
||||
</header>
|
||||
{!meeting && !error && (
|
||||
<section className="interview-card">
|
||||
<Loading />
|
||||
</section>
|
||||
)}
|
||||
{error && (
|
||||
<section className="interview-card">
|
||||
<ErrorAlert error={error} />
|
||||
</section>
|
||||
)}
|
||||
{meeting && (
|
||||
<section className="interview-card meeting-public-card">
|
||||
<span className="eyebrow">Your scheduled interview</span>
|
||||
<h1>{meeting.jobTitle}</h1>
|
||||
<p>Welcome, {meeting.participantName}.</p>
|
||||
<span className={`status status-${meeting.status}`}>{meeting.status}</span>
|
||||
<DateAndPlace interview={meeting} />
|
||||
<section className="public-document" aria-labelledby="shared-document-title">
|
||||
<h2 id="shared-document-title">Shared documentation</h2>
|
||||
<div>{meeting.sharedDocument || 'No shared documentation has been added yet.'}</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
170
frontend/src/features/interviews/ScheduledInterviewPage.tsx
Normal file
170
frontend/src/features/interviews/ScheduledInterviewPage.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { type Dispatch, type FormEvent, type SetStateAction, useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
import { useDomainEvent } from '../../app/useDomainEvent'
|
||||
import { errorMessage } from '../../components/actions'
|
||||
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
|
||||
import type { InterviewEvent, InterviewNote, ScheduledInterview } from '../../types'
|
||||
import { DateAndPlace } from './components/InterviewCard'
|
||||
import {
|
||||
AttendeeList,
|
||||
InterviewActions,
|
||||
NotesPanel,
|
||||
SessionGrid,
|
||||
SharedDocumentPanel,
|
||||
} from './components/ScheduledInterviewWorkspace'
|
||||
import { applyInterviewEvent } from './interviewEvents'
|
||||
|
||||
export function ScheduledInterviewPage() {
|
||||
const id = Number(useParams().id)
|
||||
const [interview, setInterview] = useState<ScheduledInterview>()
|
||||
const [document, setDocument] = useState('')
|
||||
const [notes, setNotes] = useState<InterviewNote[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void Promise.all([api.scheduledInterviews.get(id), api.scheduledInterviews.notes(id)])
|
||||
.then(([item, loadedNotes]) => {
|
||||
if (!active) return
|
||||
setInterview(item)
|
||||
setDocument(item.sharedDocument)
|
||||
setNotes(loadedNotes)
|
||||
})
|
||||
.catch((value) => active && setError(errorMessage(value)))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useDomainEvent<ScheduledInterview>(`scheduled-interviews/${id}`, (event) => {
|
||||
if (event.type === 'scheduled-interview.note-created') return
|
||||
setInterview(event.data)
|
||||
setDocument(event.data.sharedDocument)
|
||||
})
|
||||
|
||||
useDomainEvent<InterviewNote>(`scheduled-interviews/${id}/notes`, (event) => {
|
||||
setNotes((current) =>
|
||||
current.some((note) => note.id === event.data.id) ? current : [...current, event.data],
|
||||
)
|
||||
})
|
||||
|
||||
const interviewLoaded = interview !== undefined
|
||||
useEffect(() => {
|
||||
if (!interviewLoaded) return
|
||||
const controller = new AbortController()
|
||||
void api.scheduledInterviews
|
||||
.subscribe(
|
||||
id,
|
||||
(event) => {
|
||||
setInterview((current) => (current ? applyInterviewEvent(current, event) : current))
|
||||
applyContentEvent(event, setDocument, setNotes)
|
||||
},
|
||||
controller.signal,
|
||||
(value) => setError(errorMessage(value)),
|
||||
)
|
||||
.catch((value) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(value))
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [id, interviewLoaded])
|
||||
|
||||
async function setStatus(status: string) {
|
||||
setError('')
|
||||
try {
|
||||
await api.scheduledInterviews.setStatus(id, status)
|
||||
} catch (value) {
|
||||
setError(errorMessage(value))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDocument(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.scheduledInterviews.updateDocument(id, document)
|
||||
} catch (value) {
|
||||
setError(errorMessage(value))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function addNote(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const body = String(new FormData(form).get('body')).trim()
|
||||
if (!body) return
|
||||
setError('')
|
||||
try {
|
||||
await api.scheduledInterviews.addNote(id, body)
|
||||
form.reset()
|
||||
} catch (value) {
|
||||
setError(errorMessage(value))
|
||||
}
|
||||
}
|
||||
|
||||
async function copyParticipantLink() {
|
||||
if (!interview) return
|
||||
setError('')
|
||||
try {
|
||||
await navigator.clipboard.writeText(interview.candidateUrl)
|
||||
setLinkCopied(true)
|
||||
} catch {
|
||||
setError('Could not copy the participant interview link')
|
||||
}
|
||||
}
|
||||
|
||||
if (!interview && !error) return <Loading />
|
||||
if (!interview) return <ErrorAlert error={error} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link className="back-link" to="/schedule">
|
||||
← Schedule
|
||||
</Link>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Interviewer session</span>
|
||||
<h1>{interview.participantName}</h1>
|
||||
<p>{interview.jobTitle}</p>
|
||||
</div>
|
||||
<span className={`status status-${interview.status}`}>{interview.status}</span>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error} />}
|
||||
<DateAndPlace interview={interview} />
|
||||
<InterviewActions
|
||||
interview={interview}
|
||||
linkCopied={linkCopied}
|
||||
onCopyLink={() => void copyParticipantLink()}
|
||||
onSetStatus={(status) => void setStatus(status)}
|
||||
/>
|
||||
<SessionGrid>
|
||||
<SharedDocumentPanel
|
||||
document={document}
|
||||
saving={saving}
|
||||
onDocumentChange={setDocument}
|
||||
onSave={saveDocument}
|
||||
/>
|
||||
<NotesPanel notes={notes} canAdd={interview.status === 'started'} onAdd={addNote} />
|
||||
</SessionGrid>
|
||||
<AttendeeList interview={interview} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function applyContentEvent(
|
||||
event: InterviewEvent,
|
||||
setDocument: (value: string) => void,
|
||||
setNotes: Dispatch<SetStateAction<InterviewNote[]>>,
|
||||
) {
|
||||
if (event.type === 'document.updated') setDocument(event.sharedDocument)
|
||||
if (event.type === 'note.created') {
|
||||
setNotes((current) =>
|
||||
current.some((note) => note.id === event.note.id) ? current : [...current, event.note],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { CalendarDays, MapPin } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client'
|
||||
import { useDomainEvent } from '../../../app/useDomainEvent'
|
||||
import type { ParticipantMeeting, ScheduledInterview } from '../../../types'
|
||||
import { applyInterviewEvent } from '../interviewEvents'
|
||||
|
||||
export function DateAndPlace({
|
||||
interview,
|
||||
|
|
@ -24,19 +27,53 @@ export function DateAndPlace({
|
|||
export function InterviewCard({
|
||||
interview,
|
||||
children,
|
||||
onConnectionError,
|
||||
}: {
|
||||
interview: ScheduledInterview
|
||||
children?: ReactNode
|
||||
children?: ReactNode | ((current: ScheduledInterview) => ReactNode)
|
||||
onConnectionError?: (error: unknown) => void
|
||||
}) {
|
||||
const [current, setCurrent] = useState(interview)
|
||||
|
||||
useDomainEvent<ScheduledInterview>(`scheduled-interviews/${interview.id}`, (event) => {
|
||||
if (
|
||||
event.type === 'scheduled-interview.created' ||
|
||||
event.type === 'scheduled-interview.status-updated' ||
|
||||
event.type === 'scheduled-interview.document-updated' ||
|
||||
event.type === 'interview-invitation.responded'
|
||||
) {
|
||||
setCurrent(event.data)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const reportError = (error: unknown) => {
|
||||
if (onConnectionError) onConnectionError(error)
|
||||
else console.error('interview event stream failed', error)
|
||||
}
|
||||
void api.scheduledInterviews
|
||||
.subscribe(
|
||||
interview.id,
|
||||
(event) => setCurrent((value) => applyInterviewEvent(value, event)),
|
||||
controller.signal,
|
||||
reportError,
|
||||
)
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) reportError(error)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [interview.id, onConnectionError])
|
||||
|
||||
return (
|
||||
<article className="meeting-card">
|
||||
<div>
|
||||
<span className={`status status-${interview.status}`}>{interview.status}</span>
|
||||
<h2>{interview.participantName}</h2>
|
||||
<p>{interview.jobTitle}</p>
|
||||
<span className={`status status-${current.status}`}>{current.status}</span>
|
||||
<h2>{current.participantName}</h2>
|
||||
<p>{current.jobTitle}</p>
|
||||
</div>
|
||||
<DateAndPlace interview={interview} />
|
||||
{children}
|
||||
<DateAndPlace interview={current} />
|
||||
{typeof children === 'function' ? children(current) : children}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { Check, X } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api, authStorage } from '../../../api/client'
|
||||
import { errorMessage } from '../../../components/actions'
|
||||
import type { ScheduledInterview } from '../../../types'
|
||||
import { InterviewCard } from './InterviewCard'
|
||||
|
||||
export function InvitationCard({
|
||||
invitation,
|
||||
onError,
|
||||
}: {
|
||||
invitation: ScheduledInterview
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
async function respond(status: 'accepted' | 'declined') {
|
||||
onError('')
|
||||
try {
|
||||
await api.inbox.respond(invitation.id, status)
|
||||
} catch (error) {
|
||||
onError(errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<InterviewCard
|
||||
interview={invitation}
|
||||
onConnectionError={(error) => onError(errorMessage(error))}
|
||||
>
|
||||
{(current) =>
|
||||
(current.attendees.find((attendee) => attendee.userId === authStorage.user()?.id)?.status ??
|
||||
current.status) === 'pending' ? (
|
||||
<div className="meeting-actions">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={() => void respond('accepted')}
|
||||
>
|
||||
<Check size={17} /> Accept
|
||||
</button>
|
||||
<button
|
||||
className="button secondary danger"
|
||||
type="button"
|
||||
onClick={() => void respond('declined')}
|
||||
>
|
||||
<X size={17} /> Decline
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link className="button secondary" to={`/scheduled-interviews/${current.id}`}>
|
||||
Open session
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</InterviewCard>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { Check, Copy, UsersRound } from 'lucide-react'
|
||||
import { type FormEvent, type ReactNode } from 'react'
|
||||
import type { InterviewNote, ScheduledInterview } from '../../../types'
|
||||
|
||||
export function InterviewActions({
|
||||
interview,
|
||||
linkCopied,
|
||||
onCopyLink,
|
||||
onSetStatus,
|
||||
}: {
|
||||
interview: ScheduledInterview
|
||||
linkCopied: boolean
|
||||
onCopyLink: () => void
|
||||
onSetStatus: (status: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="session-actions">
|
||||
<button
|
||||
className="button secondary"
|
||||
type="button"
|
||||
onClick={onCopyLink}
|
||||
aria-label="Copy participant interview link"
|
||||
>
|
||||
{linkCopied ? <Check size={17} /> : <Copy size={17} />}
|
||||
{linkCopied ? 'Link copied' : 'Copy participant link'}
|
||||
</button>
|
||||
{interview.status !== 'started' && interview.status !== 'completed' && (
|
||||
<button className="button primary" type="button" onClick={() => onSetStatus('started')}>
|
||||
Start interview
|
||||
</button>
|
||||
)}
|
||||
{interview.status === 'started' && (
|
||||
<button className="button primary" type="button" onClick={() => onSetStatus('completed')}>
|
||||
Complete interview
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SharedDocumentPanel({
|
||||
document,
|
||||
saving,
|
||||
onDocumentChange,
|
||||
onSave,
|
||||
}: {
|
||||
document: string
|
||||
saving: boolean
|
||||
onDocumentChange: (value: string) => void
|
||||
onSave: (event: FormEvent<HTMLFormElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<form className="card shared-document" onSubmit={onSave}>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Visible to candidate</span>
|
||||
<h2>Shared documentation</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<textarea
|
||||
aria-label="Shared documentation"
|
||||
rows={18}
|
||||
value={document}
|
||||
onChange={(event) => onDocumentChange(event.target.value)}
|
||||
/>
|
||||
<button className="button primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save shared documentation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotesPanel({
|
||||
notes,
|
||||
canAdd,
|
||||
onAdd,
|
||||
}: {
|
||||
notes: InterviewNote[]
|
||||
canAdd: boolean
|
||||
onAdd: (event: FormEvent<HTMLFormElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="card notes-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Interviewers only</span>
|
||||
<h2>Live notes</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="notes-list" aria-live="polite">
|
||||
{notes.map((note) => (
|
||||
<article className="note" key={note.id}>
|
||||
<strong>{note.authorName}</strong>
|
||||
<time>{new Date(note.createdAt).toLocaleTimeString()}</time>
|
||||
<p>{note.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{canAdd ? (
|
||||
<form className="note-form" onSubmit={onAdd}>
|
||||
<label className="sr-only" htmlFor="interview-note">
|
||||
Add a note
|
||||
</label>
|
||||
<textarea
|
||||
id="interview-note"
|
||||
name="body"
|
||||
rows={3}
|
||||
required
|
||||
placeholder="Add a private interviewer note…"
|
||||
/>
|
||||
<button className="button primary" type="submit">
|
||||
Post note
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="panel-padding muted">Notes can be added while the interview is started.</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function AttendeeList({ interview }: { interview: ScheduledInterview }) {
|
||||
return (
|
||||
<section className="card attendee-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Attendees</h2>
|
||||
<p>Interview team and response status.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-list">
|
||||
{interview.attendees.map((attendee) => (
|
||||
<article className="user-row" key={attendee.userId}>
|
||||
<UsersRound />
|
||||
<div>
|
||||
<strong>{attendee.displayName}</strong>
|
||||
<small>{attendee.email}</small>
|
||||
</div>
|
||||
<span className={`status status-${attendee.status}`}>{attendee.status}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionGrid({ children }: { children: ReactNode }) {
|
||||
return <div className="session-grid">{children}</div>
|
||||
}
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
export { InboxPage, ParticipantMeetingPage, ScheduledInterviewPage } from './pages'
|
||||
export { InvitationPage } from './InvitationPage'
|
||||
export { ParticipantMeetingPage } from './ParticipantMeetingPage'
|
||||
export { ScheduledInterviewPage } from './ScheduledInterviewPage'
|
||||
export { ScheduleInterviewForm } from './components/ScheduleInterviewForm'
|
||||
|
|
|
|||
20
frontend/src/features/interviews/interviewEvents.ts
Normal file
20
frontend/src/features/interviews/interviewEvents.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { InterviewEvent, ScheduledInterview } from '../../types'
|
||||
|
||||
export function applyInterviewEvent(
|
||||
interview: ScheduledInterview,
|
||||
event: InterviewEvent,
|
||||
): ScheduledInterview {
|
||||
if (event.type === 'status.updated') return { ...interview, status: event.status }
|
||||
if (event.type === 'document.updated') {
|
||||
return { ...interview, sharedDocument: event.sharedDocument }
|
||||
}
|
||||
if (event.type === 'attendee.updated') {
|
||||
return {
|
||||
...interview,
|
||||
attendees: interview.attendees.map((attendee) =>
|
||||
attendee.userId === event.attendee.userId ? event.attendee : attendee,
|
||||
),
|
||||
}
|
||||
}
|
||||
return interview
|
||||
}
|
||||
|
|
@ -1,392 +0,0 @@
|
|||
import {Check, Copy, UsersRound, X} from 'lucide-react'
|
||||
import {type FormEvent, useEffect, useState} from 'react'
|
||||
import {Link, useParams} from 'react-router-dom'
|
||||
import {api} from '../../api/client'
|
||||
import type {ParticipantMeeting, ScheduledInterview} from '../../types'
|
||||
import {ErrorAlert, Loading} from '../../components/ui/AsyncState'
|
||||
import {DateAndPlace, InterviewCard} from './components/InterviewCard'
|
||||
import {message} from "../../components/actions";
|
||||
|
||||
export function InboxPage() {
|
||||
const [items, setItems] = useState<ScheduledInterview[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
void api.inbox
|
||||
.list()
|
||||
.then(setItems)
|
||||
.catch((value) => setError(message(value)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function respond(id: number, status: 'accepted' | 'declined') {
|
||||
setError('')
|
||||
try {
|
||||
const updated = await api.inbox.respond(id, status)
|
||||
setItems((current) => current.map((item) => (item.id === id ? updated : item)))
|
||||
} catch (value) {
|
||||
setError(message(value))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Invitations</span>
|
||||
<h1>Inbox</h1>
|
||||
<p>Respond to interview invitations assigned to you.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error}/>}
|
||||
{loading ? (
|
||||
<Loading/>
|
||||
) : (
|
||||
<section className="meeting-list">
|
||||
{items.map((item) => (
|
||||
<InterviewCard interview={item} key={item.id}>
|
||||
<div className="meeting-actions">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={() => void respond(item.id, 'accepted')}
|
||||
>
|
||||
<Check size={17}/> Accept
|
||||
</button>
|
||||
<button
|
||||
className="button secondary danger"
|
||||
type="button"
|
||||
onClick={() => void respond(item.id, 'declined')}
|
||||
>
|
||||
<X size={17}/> Decline
|
||||
</button>
|
||||
</div>
|
||||
</InterviewCard>
|
||||
))}
|
||||
{!items.length && (
|
||||
<div className="card empty-state">
|
||||
<h3>Inbox zero</h3>
|
||||
<p>You have no interview invitations.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function ScheduledInterviewPage() {
|
||||
const id = Number(useParams().id)
|
||||
const [interview, setInterview] = useState<ScheduledInterview>()
|
||||
const [document, setDocument] = useState('')
|
||||
const [notes, setNotes] = useState<ScheduledInterview['notes']>([])
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
const loadedInterviewID = interview?.id
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void Promise.all([api.scheduledInterviews.get(id), api.scheduledInterviews.notes(id)])
|
||||
.then(([item, loadedNotes]) => {
|
||||
if (!active) return
|
||||
setInterview(item)
|
||||
setDocument(item.sharedDocument)
|
||||
setNotes(loadedNotes)
|
||||
})
|
||||
.catch((value) => active && setError(message(value)))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadedInterviewID) return
|
||||
const controller = new AbortController()
|
||||
void api.scheduledInterviews
|
||||
.subscribe(
|
||||
id,
|
||||
(event) => {
|
||||
if (event.type === 'note.created') {
|
||||
setNotes((current) =>
|
||||
current.some((item) => item.id === event.note.id)
|
||||
? current
|
||||
: [...current, event.note],
|
||||
)
|
||||
}
|
||||
if (event.type === 'document.updated') {
|
||||
setDocument(event.sharedDocument)
|
||||
setInterview((current) =>
|
||||
current ? {...current, sharedDocument: event.sharedDocument} : current,
|
||||
)
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
(value) => setError(message(value)),
|
||||
)
|
||||
.catch((value) => {
|
||||
if (!controller.signal.aborted) setError(message(value))
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [id, loadedInterviewID])
|
||||
|
||||
async function setStatus(status: string) {
|
||||
setError('')
|
||||
try {
|
||||
setInterview(await api.scheduledInterviews.setStatus(id, status))
|
||||
} catch (value) {
|
||||
setError(message(value))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDocument(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
setInterview(await api.scheduledInterviews.updateDocument(id, document))
|
||||
} catch (value) {
|
||||
setError(message(value))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function addNote(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const body = String(new FormData(form).get('body')).trim()
|
||||
if (!body) return
|
||||
try {
|
||||
const note = await api.scheduledInterviews.addNote(id, body)
|
||||
setNotes((current) =>
|
||||
current.some((item) => item.id === note.id) ? current : [...current, note],
|
||||
)
|
||||
form.reset()
|
||||
} catch (value) {
|
||||
setError(message(value))
|
||||
}
|
||||
}
|
||||
|
||||
async function copyParticipantLink(url: string) {
|
||||
setError('')
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setLinkCopied(true)
|
||||
} catch {
|
||||
setError('Could not copy the participant interview link')
|
||||
}
|
||||
}
|
||||
|
||||
if (!interview && !error) return <Loading/>
|
||||
if (!interview) return <ErrorAlert error={error}/>
|
||||
return (
|
||||
<>
|
||||
<Link className="back-link" to="/calendar">
|
||||
← Calendar
|
||||
</Link>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Interviewer session</span>
|
||||
<h1>{interview.participantName}</h1>
|
||||
<p>{interview.jobTitle}</p>
|
||||
</div>
|
||||
<span className={`status status-${interview.status}`}>{interview.status}</span>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error}/>}
|
||||
<DateAndPlace interview={interview}/>
|
||||
<div className="session-actions">
|
||||
<button
|
||||
className="button secondary"
|
||||
type="button"
|
||||
onClick={() => void copyParticipantLink(interview.candidateUrl)}
|
||||
aria-label="Copy participant interview link"
|
||||
>
|
||||
{linkCopied ? <Check size={17}/> : <Copy size={17}/>}
|
||||
{linkCopied ? 'Link copied' : 'Copy participant link'}
|
||||
</button>
|
||||
{interview.status !== 'started' && interview.status !== 'completed' && (
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={() => void setStatus('started')}
|
||||
>
|
||||
Start interview
|
||||
</button>
|
||||
)}
|
||||
{interview.status === 'started' && (
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={() => void setStatus('completed')}
|
||||
>
|
||||
Complete interview
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-grid">
|
||||
<form className="card shared-document" onSubmit={saveDocument}>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Visible to candidate</span>
|
||||
<h2>Shared documentation</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<textarea
|
||||
aria-label="Shared documentation"
|
||||
rows={18}
|
||||
value={document}
|
||||
onChange={(event) => setDocument(event.target.value)}
|
||||
/>
|
||||
<button className="button primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save shared documentation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<section className="card notes-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Interviewers only</span>
|
||||
<h2>Live notes</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="notes-list" aria-live="polite">
|
||||
{notes.map((note) => (
|
||||
<article className="note" key={note.id}>
|
||||
<strong>{note.authorName}</strong>
|
||||
<time>{new Date(note.createdAt).toLocaleTimeString()}</time>
|
||||
<p>{note.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{interview.status === 'started' ? (
|
||||
<form className="note-form" onSubmit={addNote}>
|
||||
<label className="sr-only" htmlFor="interview-note">
|
||||
Add a note
|
||||
</label>
|
||||
<textarea
|
||||
id="interview-note"
|
||||
name="body"
|
||||
rows={3}
|
||||
required
|
||||
placeholder="Add a private interviewer note…"
|
||||
/>
|
||||
<button className="button primary" type="submit">
|
||||
Post note
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="panel-padding muted">
|
||||
Notes can be added while the interview is started.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<section className="card attendee-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Attendees</h2>
|
||||
<p>Interview team and response status.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-list">
|
||||
{interview.attendees.map((attendee) => (
|
||||
<article className="user-row" key={attendee.userId}>
|
||||
<UsersRound/>
|
||||
<div>
|
||||
<strong>{attendee.displayName}</strong>
|
||||
<small>{attendee.email}</small>
|
||||
</div>
|
||||
<span className={`status status-${attendee.status}`}>{attendee.status}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ParticipantMeetingPage() {
|
||||
const token = useParams().token ?? ''
|
||||
const [meeting, setMeeting] = useState<ParticipantMeeting>()
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let latestDocument: string | undefined
|
||||
const controller = new AbortController()
|
||||
void api.participantMeetings
|
||||
.subscribe(
|
||||
token,
|
||||
(event) => {
|
||||
if (event.type !== 'document.updated') return
|
||||
latestDocument = event.sharedDocument
|
||||
setMeeting((current) =>
|
||||
current ? {...current, sharedDocument: event.sharedDocument} : current,
|
||||
)
|
||||
},
|
||||
controller.signal,
|
||||
(value) => {
|
||||
if (active) setError(message(value))
|
||||
},
|
||||
)
|
||||
.catch((value) => {
|
||||
if (active && !controller.signal.aborted) setError(message(value))
|
||||
})
|
||||
void api.participantMeetings
|
||||
.get(token)
|
||||
.then((value) => {
|
||||
if (active) {
|
||||
setMeeting({
|
||||
...value,
|
||||
sharedDocument: latestDocument ?? value.sharedDocument,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((value) => {
|
||||
if (active) setError(message(value))
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
}
|
||||
}, [token])
|
||||
return (
|
||||
<main className="public-interview">
|
||||
<header>
|
||||
<Link className="logo" to="/" aria-label="Go Guess home">
|
||||
<span className="logo-mark">G</span>
|
||||
<span>
|
||||
<strong>GO GUESS</strong>
|
||||
<small>Talent workspace</small>
|
||||
</span>
|
||||
</Link>
|
||||
</header>
|
||||
{!meeting && !error && (
|
||||
<section className="interview-card">
|
||||
<Loading/>
|
||||
</section>
|
||||
)}
|
||||
{error && (
|
||||
<section className="interview-card">
|
||||
<ErrorAlert error={error}/>
|
||||
</section>
|
||||
)}
|
||||
{meeting && (
|
||||
<section className="interview-card meeting-public-card">
|
||||
<span className="eyebrow">Your scheduled interview</span>
|
||||
<h1>{meeting.jobTitle}</h1>
|
||||
<p>Welcome, {meeting.participantName}.</p>
|
||||
<span className={`status status-${meeting.status}`}>{meeting.status}</span>
|
||||
<DateAndPlace interview={meeting}/>
|
||||
<section className="public-document" aria-labelledby="shared-document-title">
|
||||
<h2 id="shared-document-title">Shared documentation</h2>
|
||||
<div>{meeting.sharedDocument || 'No shared documentation has been added yet.'}</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
import { useDomainEvent } from '../../app/useDomainEvent'
|
||||
import { useData } from '../../app/useData'
|
||||
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
|
||||
import { FormActions, FormField } from '../../components/form'
|
||||
|
|
@ -210,6 +211,7 @@ export function ParticipantDetailPage() {
|
|||
.then(setPerson)
|
||||
.catch((value) => setError(errorMessage(value)))
|
||||
}, [id])
|
||||
useDomainEvent<Participant>(`participants/${id}`, (event) => setPerson(event.data))
|
||||
if (error) return <ErrorAlert message={error} />
|
||||
if (!person) return <Loading />
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ArrowLeft, Pencil, Plus, Search, X } from 'lucide-react'
|
|||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../../api/client'
|
||||
import { useDomainEvent } from '../../app/useDomainEvent'
|
||||
import { ErrorAlert, errorMessage, Loading } from '../../components/actions'
|
||||
import { FormField } from '../../components/form'
|
||||
import { EmptyState } from '../../components/ui/Display'
|
||||
|
|
@ -266,25 +267,31 @@ export function QuestionLibraryPage() {
|
|||
}
|
||||
}, [query])
|
||||
|
||||
useDomainEvent<Question>('questions', (event) => {
|
||||
setQuestions((current) => {
|
||||
const existing = current.some((question) => question.id === event.data.id)
|
||||
return existing
|
||||
? current.map((question) => (question.id === event.data.id ? event.data : question))
|
||||
: [event.data, ...current]
|
||||
})
|
||||
setEditing((current) => (current?.id === event.data.id ? event.data : current))
|
||||
})
|
||||
|
||||
async function create(input: CreateQuestionInput) {
|
||||
const created = await api.questions.create(input)
|
||||
setQuestions((current) => [created, ...current])
|
||||
await api.questions.create(input)
|
||||
setFormVersion((current) => current + 1)
|
||||
}
|
||||
|
||||
async function update(input: CreateQuestionInput) {
|
||||
if (!editing) return
|
||||
const updated = await api.questions.update(editing.id, input)
|
||||
setQuestions((current) => current.map((item) => (item.id === updated.id ? updated : item)))
|
||||
await api.questions.update(editing.id, input)
|
||||
setEditing(undefined)
|
||||
}
|
||||
|
||||
async function toggle(question: Question) {
|
||||
setError('')
|
||||
try {
|
||||
const updated = await api.questions.setDeprecated(question.id, !question.deprecated)
|
||||
setQuestions((current) => current.map((item) => (item.id === updated.id ? updated : item)))
|
||||
if (editing?.id === updated.id) setEditing(updated)
|
||||
await api.questions.setDeprecated(question.id, !question.deprecated)
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import {ScheduledInterview} from "../../types";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {message} from "../../components/actions";
|
||||
import {api} from "../../api/client";
|
||||
import {ErrorAlert, Loading} from "../../components/ui/AsyncState";
|
||||
import {InterviewCard} from "../interviews/components/InterviewCard";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
export function CalendarPage() {
|
||||
const [items, setItems] = useState<ScheduledInterview[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
void api
|
||||
.calendar()
|
||||
.then(setItems)
|
||||
.catch((value) => setError(message(value)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
const groups = useMemo(() => {
|
||||
return [...items]
|
||||
.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
|
||||
.reduce<Record<string, ScheduledInterview[]>>((result, item) => {
|
||||
const key = new Date(item.startsAt).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
;(result[key] ??= []).push(item)
|
||||
return result
|
||||
}, {})
|
||||
}, [items])
|
||||
return (
|
||||
<>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Schedule</span>
|
||||
</div>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error}/>}
|
||||
{loading ? (
|
||||
<Loading/>
|
||||
) : (
|
||||
Object.entries(groups).map(([date, interviews]) => (
|
||||
<section className="calendar-day" key={date}>
|
||||
<h2>{date}</h2>
|
||||
<div className="meeting-list">
|
||||
{interviews.map((item) => (
|
||||
<InterviewCard interview={item} key={item.id}>
|
||||
<Link className="button secondary" to={`/scheduled-interviews/${item.id}`}>
|
||||
Open session
|
||||
</Link>
|
||||
</InterviewCard>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
81
frontend/src/features/users/SchedulePage.tsx
Normal file
81
frontend/src/features/users/SchedulePage.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api, authStorage } from '../../api/client'
|
||||
import { useDomainEvent } from '../../app/useDomainEvent'
|
||||
import { errorMessage } from '../../components/actions'
|
||||
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
|
||||
import type { ScheduledInterview } from '../../types'
|
||||
import { InterviewCard } from '../interviews/components/InterviewCard'
|
||||
|
||||
export function SchedulePage() {
|
||||
const [items, setItems] = useState<ScheduledInterview[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
void api
|
||||
.calendar()
|
||||
.then(setItems)
|
||||
.catch((value) => setError(errorMessage(value)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useDomainEvent<ScheduledInterview>('schedule', (event) => {
|
||||
setItems((current) => {
|
||||
const attendee = event.data.attendees.find((item) => item.userId === authStorage.user()?.id)
|
||||
const accepted = attendee?.status === 'accepted'
|
||||
if (!accepted) return current.filter((item) => item.id !== event.data.id)
|
||||
return [event.data, ...current.filter((item) => item.id !== event.data.id)]
|
||||
})
|
||||
})
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return [...items]
|
||||
.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
|
||||
.reduce<Record<string, ScheduledInterview[]>>((result, item) => {
|
||||
const key = new Date(item.startsAt).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
;(result[key] ??= []).push(item)
|
||||
return result
|
||||
}, {})
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Interviews</span>
|
||||
<h1>Schedule</h1>
|
||||
<p>Review accepted and upcoming interviews.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error} />}
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
Object.entries(groups).map(([date, interviews]) => (
|
||||
<section className="calendar-day" key={date}>
|
||||
<h2>{date}</h2>
|
||||
<div className="meeting-list">
|
||||
{interviews.map((item) => (
|
||||
<InterviewCard
|
||||
interview={item}
|
||||
key={item.id}
|
||||
onConnectionError={(value) => setError(errorMessage(value))}
|
||||
>
|
||||
<Link className="button secondary" to={`/scheduled-interviews/${item.id}`}>
|
||||
Open session
|
||||
</Link>
|
||||
</InterviewCard>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Plus } from 'lucide-react'
|
||||
import { type FormEvent, useEffect, useState } from 'react'
|
||||
import { api, ApiError } from '../../api/client'
|
||||
import { useDomainEvent } from '../../app/useDomainEvent'
|
||||
import { ErrorAlert, Loading } from '../../components/ui/AsyncState'
|
||||
import type { User } from '../../types'
|
||||
|
||||
|
|
@ -24,6 +25,10 @@ export function UsersPage() {
|
|||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useDomainEvent<User>('users', (event) => {
|
||||
setUsers((current) => [...current.filter((user) => user.id !== event.data.id), event.data])
|
||||
})
|
||||
|
||||
async function create(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
|
|
@ -31,12 +36,11 @@ export function UsersPage() {
|
|||
const form = event.currentTarget
|
||||
const values = new FormData(form)
|
||||
try {
|
||||
const created = await api.users.create({
|
||||
await api.users.create({
|
||||
email: String(values.get('email')),
|
||||
password: String(values.get('password')),
|
||||
displayName: String(values.get('displayName')),
|
||||
})
|
||||
setUsers((current) => [...current, created])
|
||||
form.reset()
|
||||
} catch (value) {
|
||||
setError(errorMessage(value))
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export {ProfilePage} from './ProfilePage';
|
||||
export {UsersPage} from './UsersPage';
|
||||
export {CalendarPage} from './CalendarPage';
|
||||
export { ProfilePage } from './ProfilePage'
|
||||
export { SchedulePage } from './SchedulePage'
|
||||
export { UsersPage } from './UsersPage'
|
||||
|
|
|
|||
|
|
@ -101,6 +101,16 @@ export type InterviewEvent =
|
|||
interviewId: number
|
||||
sharedDocument: string
|
||||
}
|
||||
| {
|
||||
type: 'status.updated'
|
||||
interviewId: number
|
||||
status: string
|
||||
}
|
||||
| {
|
||||
type: 'attendee.updated'
|
||||
interviewId: number
|
||||
attendee: InterviewAttendee
|
||||
}
|
||||
|
||||
export interface ScheduledInterview {
|
||||
id: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue