refac: stale test entries, failing web tests, Postgre backup bug, Hardcoded MySQL table count, Stale Postgre connection in test, own-scope visibility filter, GetAll ignoring its error, JSON tags in use-case types, 31 copies of DESKTOP_QUERY, ProfileView endpoint paths

This commit is contained in:
Florian Sylvain committed 2026-10-28 17:54:10 +01:00
1 parent efecdee200
commit 5d6f378fce
87 files changed
+669 -346

No files matched your search

+6 -1
View File
@@ -139,7 +139,12 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
} }
func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request) { func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request) {
body, _ := json.Marshal(map[string]bool{"open": h.CreateUseCase.IsRegistrationOpen()}) open, err := h.CreateUseCase.IsRegistrationOpen()
if err != nil {
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
return
}
body, _ := json.Marshal(map[string]bool{"open": open})
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body) _, _ = w.Write(body)
} }
+5 -4
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/payload"
"RenewCMS/api/pkg/publicuser" "RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role" "RenewCMS/internal/domain/role"
@@ -33,9 +34,9 @@ type meAuthor struct {
} }
type meAccess struct { type meAccess struct {
Role useCases.RoleRef `json:"role"` Role payload.RoleRef `json:"role"`
Permissions []role.Grant `json:"permissions"` Permissions []role.Grant `json:"permissions"`
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"` RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
} }
type meResponse struct { type meResponse struct {
@@ -46,9 +47,9 @@ type meResponse struct {
func toMeAccess(access useCases.Access) meAccess { func toMeAccess(access useCases.Access) meAccess {
return meAccess{ return meAccess{
Role: useCases.NewRoleRef(access.Role), Role: payload.RoleRefOf(useCases.NewRoleRef(access.Role)),
Permissions: access.Permissions, Permissions: access.Permissions,
RoleAssignedBy: access.RoleAssignedBy, RoleAssignedBy: payload.RoleAssignerPtrOf(access.RoleAssignedBy),
} }
} }
+2 -1
View File
@@ -3,6 +3,7 @@ package ownership
import ( import (
"RenewCMS/api/pkg/access" "RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
@@ -45,7 +46,7 @@ func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := json.Marshal(transferred) body, err := json.Marshal(payload.OwnershipTransferOf(transferred))
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
+6 -5
View File
@@ -3,6 +3,7 @@ package role
import ( import (
"RenewCMS/api/pkg/access" "RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/role" domain "RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
@@ -152,7 +153,7 @@ func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
writeRoleError(w, err) writeRoleError(w, err)
return return
} }
writeJSON(w, http.StatusOK, listed) writeJSON(w, http.StatusOK, payload.RoleViewsOf(listed))
} }
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
@@ -165,7 +166,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
writeRoleError(w, err) writeRoleError(w, err)
return return
} }
writeJSON(w, http.StatusOK, found) writeJSON(w, http.StatusOK, payload.RoleViewOf(found))
} }
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
@@ -180,7 +181,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
writeRoleError(w, err) writeRoleError(w, err)
return return
} }
writeJSON(w, http.StatusCreated, created) writeJSON(w, http.StatusCreated, payload.RoleViewOf(created))
} }
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
@@ -199,7 +200,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
writeRoleError(w, err) writeRoleError(w, err)
return return
} }
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, payload.RoleViewOf(updated))
} }
func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) { func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
@@ -216,7 +217,7 @@ func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
writeRoleError(w, err) writeRoleError(w, err)
return return
} }
writeJSON(w, http.StatusOK, restored) writeJSON(w, http.StatusOK, payload.RoleViewOf(restored))
} }
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
+7 -6
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/api/pkg/access" "RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims" "RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/payload"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
@@ -34,7 +35,7 @@ type inviteResponse struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Email string `json:"email"` Email string `json:"email"`
Status string `json:"status"` Status string `json:"status"`
Role useCases.RoleRef `json:"role"` Role payload.RoleRef `json:"role"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
} }
@@ -43,7 +44,7 @@ type inviteListItem struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Kind string `json:"kind"` Kind string `json:"kind"`
Email string `json:"email"` Email string `json:"email"`
Role *useCases.RoleRef `json:"role"` Role *payload.RoleRef `json:"role"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -54,8 +55,8 @@ type userListItem struct {
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Email string `json:"email"` Email string `json:"email"`
Role *useCases.RoleRef `json:"role"` Role *payload.RoleRef `json:"role"`
RoleAssignedBy *useCases.RoleAssigner `json:"role_assigned_by"` RoleAssignedBy *payload.RoleAssigner `json:"role_assigned_by"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -107,7 +108,7 @@ func writeInviteResponse(w http.ResponseWriter, status int, entry useCases.Invit
Kind: useCases.EntryKindInvite, Kind: useCases.EntryKindInvite,
Email: entry.Email, Email: entry.Email,
Status: string(entry.StatusAt(time.Now())), Status: string(entry.StatusAt(time.Now())),
Role: entry.Role, Role: payload.RoleRefOf(entry.Role),
CreatedAt: entry.CreatedAt, CreatedAt: entry.CreatedAt,
ExpiresAt: entry.ExpiresAt, ExpiresAt: entry.ExpiresAt,
}) })
@@ -212,7 +213,7 @@ func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
ID: entry.ID, ID: entry.ID,
Kind: entry.Kind, Kind: entry.Kind,
Email: entry.Email, Email: entry.Email,
Role: entry.Role, Role: payload.RoleRefPtrOf(entry.Role),
Status: entry.Status, Status: entry.Status,
CreatedAt: entry.CreatedAt, CreatedAt: entry.CreatedAt,
}) })
+3 -2
View File
@@ -2,6 +2,7 @@ package user
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/payload"
"RenewCMS/internal/domain/role" "RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
@@ -16,8 +17,8 @@ func userListItemOf(entry useCases.DirectoryEntry) userListItem {
Username: entry.Username, Username: entry.Username,
DisplayName: entry.DisplayName, DisplayName: entry.DisplayName,
Email: entry.Email, Email: entry.Email,
Role: entry.Role, Role: payload.RoleRefPtrOf(entry.Role),
RoleAssignedBy: entry.RoleAssignedBy, RoleAssignedBy: payload.RoleAssignerPtrOf(entry.RoleAssignedBy),
Status: entry.Status, Status: entry.Status,
CreatedAt: entry.CreatedAt, CreatedAt: entry.CreatedAt,
} }
+91
View File
@@ -0,0 +1,91 @@
package payload
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"time"
)
type RoleRef struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
}
type RoleAssigner struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleEditor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleView struct {
role.Role
Members int `json:"members"`
UpdatedAt *time.Time `json:"updated_at"`
UpdatedBy *RoleEditor `json:"updated_by"`
}
type OwnershipSuccessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type OwnershipPredecessor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Role RoleRef `json:"role"`
}
type OwnershipTransfer struct {
Owner OwnershipSuccessor `json:"owner"`
FormerOwner OwnershipPredecessor `json:"former_owner"`
}
func RoleRefOf(ref useCases.RoleRef) RoleRef {
return RoleRef(ref)
}
func RoleRefPtrOf(ref *useCases.RoleRef) *RoleRef {
if ref == nil {
return nil
}
converted := RoleRefOf(*ref)
return &converted
}
func RoleAssignerPtrOf(assigner *useCases.RoleAssigner) *RoleAssigner {
if assigner == nil {
return nil
}
converted := RoleAssigner(*assigner)
return &converted
}
func RoleViewOf(view useCases.RoleView) RoleView {
converted := RoleView{Role: view.Role, Members: view.Members, UpdatedAt: view.UpdatedAt}
if view.UpdatedBy != nil {
editor := RoleEditor(*view.UpdatedBy)
converted.UpdatedBy = &editor
}
return converted
}
func RoleViewsOf(views []useCases.RoleView) []RoleView {
converted := make([]RoleView, 0, len(views))
for _, view := range views {
converted = append(converted, RoleViewOf(view))
}
return converted
}
func OwnershipTransferOf(transfer useCases.OwnershipTransfer) OwnershipTransfer {
return OwnershipTransfer{
Owner: OwnershipSuccessor(transfer.Owner),
FormerOwner: OwnershipPredecessor{ID: transfer.FormerOwner.ID, Name: transfer.FormerOwner.Name, Role: RoleRefOf(transfer.FormerOwner.Role)},
}
}
@@ -32,6 +32,7 @@ func DefaultSort() Sort {
type Filters struct { type Filters struct {
IsOnline *bool IsOnline *bool
SiteID *uint32 SiteID *uint32
VisibleTo *uint32
Page int Page int
Size int Size int
Q string Q string
@@ -89,9 +90,10 @@ type Repository interface {
FindByFilters(filters Filters) ([]Article, error) FindByFilters(filters Filters) ([]Article, error)
FindPage(filters Filters) (PageResult, error) FindPage(filters Filters) (PageResult, error)
CountByVisibility(onlineOnly bool) (Counts, error) CountByVisibility(onlineOnly bool) (Counts, error)
CountVisibleTo(userID uint32) (Counts, error)
Get(id uint32) (Article, error) Get(id uint32) (Article, error)
GetByName(name string) (Article, error) GetByName(name string) (Article, error)
GetAll() []Article GetAll() ([]Article, error)
Create(post Article) (Article, error) Create(post Article) (Article, error)
UpdateBody(id uint32, body string) (Article, error) UpdateBody(id uint32, body string) (Article, error)
Update(id uint32, update Update) (Article, error) Update(id uint32, update Update) (Article, error)
@@ -48,6 +48,7 @@ func DefaultSort() Sort {
type Filters struct { type Filters struct {
ContentTypeID uint32 ContentTypeID uint32
CreatedBy *uint32
Page int Page int
Size int Size int
Sort Sort Sort Sort
@@ -7,7 +7,7 @@ import (
type Repository interface { type Repository interface {
Create(invite Invite) (Invite, error) Create(invite Invite) (Invite, error)
GetAll() []Invite GetAll() ([]Invite, error)
Delete(id uint32) error Delete(id uint32) error
Renew(id uint32, hashedCode string, expiresAt time.Time, createdAt time.Time) (Invite, error) Renew(id uint32, hashedCode string, expiresAt time.Time, createdAt time.Time) (Invite, error)
AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser user.User) (user.User, error) AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser user.User) (user.User, error)
@@ -6,7 +6,7 @@ type Repository interface {
Get(id uint32) (User, error) Get(id uint32) (User, error)
GetByUsername(username string) (User, error) GetByUsername(username string) (User, error)
GetByEmail(email string) (User, error) GetByEmail(email string) (User, error)
GetAll() []User GetAll() ([]User, error)
FindByIDs(ids []uint32) ([]User, error) FindByIDs(ids []uint32) ([]User, error)
Create(user User) (User, error) Create(user User) (User, error)
Delete(id uint32) error Delete(id uint32) error
@@ -48,6 +48,8 @@ var postgresNumericTypes = map[string]bool{
"INT2": true, "INT4": true, "INT8": true, "FLOAT4": true, "FLOAT8": true, "NUMERIC": true, "OID": true, "INT2": true, "INT4": true, "INT8": true, "FLOAT4": true, "FLOAT8": true, "NUMERIC": true, "OID": true,
} }
var postgresIntegerTypes = map[string]bool{"INT2": true, "INT4": true, "INT8": true}
var postgresSerialTypes = map[string]string{ var postgresSerialTypes = map[string]string{
"smallint": "smallserial", "smallint": "smallserial",
"integer": "serial", "integer": "serial",
@@ -175,7 +177,7 @@ func postgresLiteral(databaseType string, value []byte) string {
func postgresSequenceReset(table string, columns []DumpColumn) []string { func postgresSequenceReset(table string, columns []DumpColumn) []string {
for _, column := range columns { for _, column := range columns {
if column.Name != postgresIdentityColumn { if column.Name != postgresIdentityColumn || !postgresIntegerTypes[strings.ToUpper(column.Type)] {
continue continue
} }
quotedTable, quotedColumn := quotePostgresIdentifier(table), quotePostgresIdentifier(column.Name) quotedTable, quotedColumn := quotePostgresIdentifier(table), quotePostgresIdentifier(column.Name)
@@ -46,6 +46,9 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
if filters.SiteID != nil { if filters.SiteID != nil {
query = query.Where("site_id = ?", *filters.SiteID) query = query.Where("site_id = ?", *filters.SiteID)
} }
if filters.VisibleTo != nil {
query = query.Where("(is_online = ? OR created_by = ?)", true, *filters.VisibleTo)
}
switch filters.Status { switch filters.Status {
case domain.StatusOnline: case domain.StatusOnline:
query = query.Where("is_online = ?", true) query = query.Where("is_online = ?", true)
@@ -168,6 +171,17 @@ func (a *ArticleRepository) CountByVisibility(onlineOnly bool) (domain.Counts, e
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
} }
func (a *ArticleRepository) CountVisibleTo(userID uint32) (domain.Counts, error) {
var online, draft int64
if err := a.db.Model(&entity.Article{}).Where("is_online = ?", true).Count(&online).Error; err != nil {
return domain.Counts{}, err
}
if err := a.db.Model(&entity.Article{}).Where("is_online = ? AND created_by = ?", false, userID).Count(&draft).Error; err != nil {
return domain.Counts{}, err
}
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
}
func toDomainArticles(entityArticles []entity.Article) []domain.Article { func toDomainArticles(entityArticles []entity.Article) []domain.Article {
domainArticles := make([]domain.Article, 0, len(entityArticles)) domainArticles := make([]domain.Article, 0, len(entityArticles))
for _, entityArticle := range entityArticles { for _, entityArticle := range entityArticles {
@@ -356,11 +370,11 @@ func (a *ArticleRepository) SlugTaken(siteID uint32, slug string, exceptID uint3
return articleSlugInUse(a.db, siteID, slug, exceptID) return articleSlugInUse(a.db, siteID, slug, exceptID)
} }
func (a *ArticleRepository) GetAll() []domain.Article { func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
var articles []entity.Article var articles []entity.Article
err := a.db.Model(&entity.Article{}).Find(&articles).Error err := a.db.Model(&entity.Article{}).Find(&articles).Error
if err != nil { if err != nil {
return []domain.Article{} return nil, err
} }
var domainArticles = make([]domain.Article, 0) var domainArticles = make([]domain.Article, 0)
@@ -368,7 +382,7 @@ func (a *ArticleRepository) GetAll() []domain.Article {
domainArticles = append(domainArticles, mappers.ArticleToDomain(article)) domainArticles = append(domainArticles, mappers.ArticleToDomain(article))
} }
return domainArticles return domainArticles, nil
} }
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) { func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
@@ -71,7 +71,11 @@ func (e *EntryRepository) ListPage(filters domain.Filters) (domain.PageResult, e
filters.Page = max(filters.Page, 1) filters.Page = max(filters.Page, 1)
filters.Size = max(filters.Size, 0) filters.Size = max(filters.Size, 0)
scoped := func() *gorm.DB { scoped := func() *gorm.DB {
return e.db.Model(&entity.Entry{}).Where("content_type_id = ?", filters.ContentTypeID) query := e.db.Model(&entity.Entry{}).Where("content_type_id = ?", filters.ContentTypeID)
if filters.CreatedBy != nil {
query = query.Where("created_by = ?", *filters.CreatedBy)
}
return query
} }
var total int64 var total int64
if err := scoped().Count(&total).Error; err != nil { if err := scoped().Count(&total).Error; err != nil {
@@ -28,16 +28,18 @@ func (i *InviteRepository) Create(invite domainInvite.Invite) (domainInvite.Invi
return mappers.InviteToDomain(model), nil return mappers.InviteToDomain(model), nil
} }
func (i *InviteRepository) GetAll() []domainInvite.Invite { func (i *InviteRepository) GetAll() ([]domainInvite.Invite, error) {
var invites []entity.Invite var invites []entity.Invite
i.db.Model(&entity.Invite{}).Find(&invites) if err := i.db.Model(&entity.Invite{}).Find(&invites).Error; err != nil {
return nil, err
}
var domainInvites []domainInvite.Invite var domainInvites []domainInvite.Invite
for _, localInvite := range invites { for _, localInvite := range invites {
domainInvites = append(domainInvites, mappers.InviteToDomain(localInvite)) domainInvites = append(domainInvites, mappers.InviteToDomain(localInvite))
} }
return domainInvites return domainInvites, nil
} }
func (i *InviteRepository) Delete(id uint32) error { func (i *InviteRepository) Delete(id uint32) error {
@@ -52,16 +52,18 @@ func (u *UserRepository) Delete(id uint32) error {
return deleteOrNotFound(result, domainUser.ErrUserNotFound) return deleteOrNotFound(result, domainUser.ErrUserNotFound)
} }
func (u *UserRepository) GetAll() []domainUser.User { func (u *UserRepository) GetAll() ([]domainUser.User, error) {
var users []entity.User var users []entity.User
u.db.Model(&entity.User{}).Find(&users) if err := u.db.Model(&entity.User{}).Find(&users).Error; err != nil {
return nil, err
}
var domainUsers []domainUser.User var domainUsers []domainUser.User
for _, localUser := range users { for _, localUser := range users {
domainUsers = append(domainUsers, mappers.UserToDomain(localUser)) domainUsers = append(domainUsers, mappers.UserToDomain(localUser))
} }
return domainUsers return domainUsers, nil
} }
func (u *UserRepository) FindByIDs(ids []uint32) ([]domainUser.User, error) { func (u *UserRepository) FindByIDs(ids []uint32) ([]domainUser.User, error) {
@@ -72,7 +72,11 @@ func (a *AcceptInviteUseCase) recordRefusal(ctx context.Context, event audit.Eve
} }
func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite.Invite, assigned role.Role, now time.Time) (user.User, error) { func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite.Invite, assigned role.Role, now time.Time) (user.User, error) {
if a.emailHasAccount(pending.Email) { hasAccount, err := a.emailHasAccount(pending.Email)
if err != nil {
return user.User{}, err
}
if hasAccount {
if _, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now); err != nil { if _, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now); err != nil {
return user.User{}, err return user.User{}, err
} }
@@ -122,13 +126,17 @@ func (a *AcceptInviteUseCase) inviterOf(pending invite.Invite) (uint32, error) {
return assigner.ID, nil return assigner.ID, nil
} }
func (a *AcceptInviteUseCase) emailHasAccount(email string) bool { func (a *AcceptInviteUseCase) emailHasAccount(email string) (bool, error) {
for _, existingUser := range a.userRepository.GetAll() { users, err := a.userRepository.GetAll()
if err != nil {
return false, err
}
for _, existingUser := range users {
if strings.EqualFold(existingUser.Email, email) { if strings.EqualFold(existingUser.Email, email) {
return true return true, nil
} }
} }
return false return false, nil
} }
func (a *AcceptInviteUseCase) ensureUsernameFree(username string) error { func (a *AcceptInviteUseCase) ensureUsernameFree(username string) error {
@@ -98,7 +98,11 @@ func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite
return invite.Invite{}, role.Role{}, err return invite.Invite{}, role.Role{}, err
} }
if g.emailHasAccount(email) { hasAccount, err := g.emailHasAccount(email)
if err != nil {
return invite.Invite{}, role.Role{}, err
}
if hasAccount {
return invite.Invite{}, role.Role{}, invite.ErrEmailHasAccount return invite.Invite{}, role.Role{}, invite.ErrEmailHasAccount
} }
@@ -133,17 +137,25 @@ func (g *CreateInviteUseCase) create(command CreateInviteCommand) (invite.Invite
return createdInvite, assigned, nil return createdInvite, assigned, nil
} }
func (g *CreateInviteUseCase) emailHasAccount(email string) bool { func (g *CreateInviteUseCase) emailHasAccount(email string) (bool, error) {
for _, existingUser := range g.userRepository.GetAll() { users, err := g.userRepository.GetAll()
if err != nil {
return false, err
}
for _, existingUser := range users {
if strings.EqualFold(existingUser.Email, email) { if strings.EqualFold(existingUser.Email, email) {
return true return true, nil
} }
} }
return false return false, nil
} }
func (g *CreateInviteUseCase) discardOpenInvite(email string, now time.Time) error { func (g *CreateInviteUseCase) discardOpenInvite(email string, now time.Time) error {
for _, existing := range g.inviteRepository.GetAll() { invites, err := g.inviteRepository.GetAll()
if err != nil {
return err
}
for _, existing := range invites {
if existing.Email != email { if existing.Email != email {
continue continue
} }
+16 -7
View File
@@ -53,7 +53,11 @@ func (g *CreateUserUseCase) CreateUser(ctx context.Context, createUser CreateUse
} }
func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, error) { func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, error) {
if g.registrationClosed() { closed, err := g.registrationClosed()
if err != nil {
return user.User{}, err
}
if closed {
return user.User{}, user.ErrRegistrationClosed return user.User{}, user.ErrRegistrationClosed
} }
@@ -108,15 +112,20 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err
return createdUser, nil return createdUser, nil
} }
func (g *CreateUserUseCase) IsRegistrationOpen() bool { func (g *CreateUserUseCase) IsRegistrationOpen() (bool, error) {
return !g.registrationClosed() closed, err := g.registrationClosed()
return !closed, err
} }
func (g *CreateUserUseCase) registrationClosed() bool { func (g *CreateUserUseCase) registrationClosed() (bool, error) {
for _, existingUser := range g.userRepository.GetAll() { users, err := g.userRepository.GetAll()
if err != nil {
return false, err
}
for _, existingUser := range users {
if existingUser.IsVerified { if existingUser.IsVerified {
return true return true, nil
} }
} }
return false return false, nil
} }
@@ -34,7 +34,11 @@ func (e *EnforceRetentionUseCase) Tick(now time.Time) (retention.Result, error)
func (e *EnforceRetentionUseCase) deleteStaleUnverifiedUsers(now time.Time) (int, error) { func (e *EnforceRetentionUseCase) deleteStaleUnverifiedUsers(now time.Time) (int, error) {
removed := 0 removed := 0
for _, candidate := range e.users.GetAll() { users, err := e.users.GetAll()
if err != nil {
return 0, err
}
for _, candidate := range users {
if !candidate.IsStaleUnverifiedAt(now) { if !candidate.IsStaleUnverifiedAt(now) {
continue continue
} }
@@ -48,7 +52,11 @@ func (e *EnforceRetentionUseCase) deleteStaleUnverifiedUsers(now time.Time) (int
func (e *EnforceRetentionUseCase) deleteStaleInvites(now time.Time) (int, error) { func (e *EnforceRetentionUseCase) deleteStaleInvites(now time.Time) (int, error) {
removed := 0 removed := 0
for _, candidate := range e.invites.GetAll() { invites, err := e.invites.GetAll()
if err != nil {
return 0, err
}
for _, candidate := range invites {
if !candidate.IsStaleAt(now) { if !candidate.IsStaleAt(now) {
continue continue
} }
@@ -24,7 +24,11 @@ func findUsableInvite(inviteRepository invite.Repository, hasher hash.Hasher, co
return invite.Invite{}, invite.ErrInviteInvalid return invite.Invite{}, invite.ErrInviteInvalid
} }
for _, candidate := range inviteRepository.GetAll() { invites, err := inviteRepository.GetAll()
if err != nil {
return invite.Invite{}, err
}
for _, candidate := range invites {
if candidate.AcceptedAt != nil { if candidate.AcceptedAt != nil {
continue continue
} }
@@ -42,20 +42,42 @@ type ArticlePage struct {
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) { func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
switch command.Caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) { switch command.Caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) {
case role.ScopeAll: case role.ScopeAll:
return u.listVisibleByRepository(command, false) return u.listVisibleByRepository(command, articleVisibility{})
case role.ScopeOwn: case role.ScopeOwn:
return u.listOwnedAndOnline(command) return u.listVisibleByRepository(command, articleVisibility{userID: &command.Caller.UserID})
default: default:
return u.listVisibleByRepository(command, true) return u.listVisibleByRepository(command, articleVisibility{onlineOnly: true})
} }
} }
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, onlineOnly bool) (ArticlePage, error) { type articleVisibility struct {
counts, err := u.articleRepository.CountByVisibility(onlineOnly) onlineOnly bool
userID *uint32
}
func (v articleVisibility) apply(filters article.Filters) article.Filters {
if v.onlineOnly {
online := true
filters.IsOnline = &online
}
filters.VisibleTo = v.userID
return filters
}
func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (article.Counts, error) {
if visibility.userID != nil {
return u.articleRepository.CountVisibleTo(*visibility.userID)
}
return u.articleRepository.CountByVisibility(visibility.onlineOnly)
}
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, visibility articleVisibility) (ArticlePage, error) {
onlineOnly := visibility.onlineOnly
counts, err := u.countVisible(visibility)
if err != nil { if err != nil {
return ArticlePage{}, err return ArticlePage{}, err
} }
summaries, err := u.summarizeVisible(onlineOnly) summaries, err := u.summarizeVisible(visibility)
if err != nil { if err != nil {
return ArticlePage{}, err return ArticlePage{}, err
} }
@@ -64,7 +86,7 @@ func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesComman
if onlineOnly && command.Status == article.StatusDraft { if onlineOnly && command.Status == article.StatusDraft {
return page, nil return page, nil
} }
filters := pageFilters(command, onlineOnly) filters := visibility.apply(pageFilters(command))
if command.SiteKey != nil { if command.SiteKey != nil {
siteID, known, err := u.siteIDOf(*command.SiteKey) siteID, known, err := u.siteIDOf(*command.SiteKey)
if err != nil { if err != nil {
@@ -98,71 +120,6 @@ func emptyArticlePage(command ListArticlesCommand, counts article.Counts) Articl
return ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}} return ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
} }
func (u *ListArticlesUseCase) listOwnedAndOnline(command ListArticlesCommand) (ArticlePage, error) {
everything, err := u.articleRepository.FindPage(article.Filters{Unpaged: true})
if err != nil {
return ArticlePage{}, err
}
visible := visibleTo(command.Caller, everything.Items)
summaries, err := u.summarize(visible)
if err != nil {
return ArticlePage{}, err
}
counts := tally(visible)
counts.Suggestions = countSuggestions(summaries)
page := emptyArticlePage(command, counts)
filters := pageFilters(command, false)
filters.Unpaged = true
if command.SiteKey != nil {
siteID, known, err := u.siteIDOf(*command.SiteKey)
if err != nil {
return ArticlePage{}, err
}
if !known {
return page, nil
}
filters.SiteID = &siteID
}
found, err := u.articleRepository.FindPage(filters)
if err != nil {
return ArticlePage{}, err
}
matching := visibleTo(command.Caller, found.Items)
if command.SEO != "" {
matching = withSEOStatus(matching, summaries, command.SEO)
}
rendered, err := renderArticles(u.bodyRenderer, pageSlice(matching, command.Page, command.Size), command.Format)
if err != nil {
return ArticlePage{}, err
}
page.Items = rendered
page.Total = len(matching)
page.SEO = summaries
return page, nil
}
func visibleTo(caller ContentCaller, items []article.Article) []article.Article {
visible := make([]article.Article, 0, len(items))
for _, item := range items {
if caller.seesArticle(item) {
visible = append(visible, item)
}
}
return visible
}
func tally(visible []article.Article) article.Counts {
counts := article.Counts{All: len(visible)}
for _, item := range visible {
if item.IsOnline {
counts.Online++
} else {
counts.Draft++
}
}
return counts
}
func withSEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, status string) []article.Article { func withSEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, status string) []article.Article {
matching := make([]article.Article, 0, len(items)) matching := make([]article.Article, 0, len(items))
for _, item := range items { for _, item := range items {
@@ -187,13 +144,8 @@ func pageSlice[T any](items []T, page int, size int) []T {
return items[start:min(start+size, len(items))] return items[start:min(start+size, len(items))]
} }
func (u *ListArticlesUseCase) summarizeVisible(onlineOnly bool) (map[uint32]article.SEOSummary, error) { func (u *ListArticlesUseCase) summarizeVisible(visibility articleVisibility) (map[uint32]article.SEOSummary, error) {
filters := article.Filters{Unpaged: true} found, err := u.articleRepository.FindPage(visibility.apply(article.Filters{Unpaged: true}))
if onlineOnly {
online := true
filters.IsOnline = &online
}
found, err := u.articleRepository.FindPage(filters)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -245,11 +197,6 @@ func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
return found.ID, true, nil return found.ID, true, nil
} }
func pageFilters(command ListArticlesCommand, onlineOnly bool) article.Filters { func pageFilters(command ListArticlesCommand) article.Filters {
filters := article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort} return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort}
if onlineOnly {
online := true
filters.IsOnline = &online
}
return filters
} }
@@ -70,7 +70,14 @@ func (u *ListEntriesUseCase) page(owner contenttype.ContentType, command ListEnt
if query == "" && readScope == role.ScopeAll { if query == "" && readScope == role.ScopeAll {
return u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort}) return u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort})
} }
all, err := u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Unpaged: true, Sort: command.Sort}) if query == "" && readScope == role.ScopeOwn {
return u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, CreatedBy: &command.Caller.UserID, Page: command.Page, Size: command.Size, Sort: command.Sort})
}
filters := entry.Filters{ContentTypeID: owner.ID, Unpaged: true, Sort: command.Sort}
if readScope == role.ScopeOwn {
filters.CreatedBy = &command.Caller.UserID
}
all, err := u.entries.ListPage(filters)
if err != nil { if err != nil {
return entry.PageResult{}, err return entry.PageResult{}, err
} }
@@ -10,6 +10,6 @@ func NewListUsersUseCase(userRepository user.Repository) *ListUsersUseCase {
return &ListUsersUseCase{userRepository} return &ListUsersUseCase{userRepository}
} }
func (g *ListUsersUseCase) ListUsers() []user.User { func (g *ListUsersUseCase) ListUsers() ([]user.User, error) {
return g.userRepository.GetAll() return g.userRepository.GetAll()
} }
@@ -21,8 +21,8 @@ type ListUsersAndInvitesUseCase struct {
} }
type RoleAssigner struct { type RoleAssigner struct {
ID uint32 `json:"id"` ID uint32
Name string `json:"name"` Name string
} }
func newRoleAssigner(account user.User) *RoleAssigner { func newRoleAssigner(account user.User) *RoleAssigner {
@@ -89,7 +89,10 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() ([]DirectoryEntry, er
now := time.Now() now := time.Now()
entries := []DirectoryEntry{} entries := []DirectoryEntry{}
users := g.userRepository.GetAll() users, err := g.userRepository.GetAll()
if err != nil {
return nil, err
}
loaded := make(map[uint32]user.User, len(users)) loaded := make(map[uint32]user.User, len(users))
for _, existingUser := range users { for _, existingUser := range users {
loaded[existingUser.ID] = existingUser loaded[existingUser.ID] = existingUser
@@ -98,7 +101,11 @@ func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() ([]DirectoryEntry, er
entries = append(entries, userEntry(existingUser, lookupRoleRef(refs, existingUser.RoleID), assignerAmong(loaded, existingUser.RoleAssignedByID))) entries = append(entries, userEntry(existingUser, lookupRoleRef(refs, existingUser.RoleID), assignerAmong(loaded, existingUser.RoleAssignedByID)))
} }
for _, existingInvite := range g.inviteRepository.GetAll() { invites, err := g.inviteRepository.GetAll()
if err != nil {
return nil, err
}
for _, existingInvite := range invites {
status := existingInvite.StatusAt(now) status := existingInvite.StatusAt(now)
if status == invite.StatusAccepted { if status == invite.StatusAccepted {
continue continue
@@ -21,19 +21,19 @@ type TransferOwnershipCommand struct {
} }
type OwnershipSuccessor struct { type OwnershipSuccessor struct {
ID uint32 `json:"id"` ID uint32
Name string `json:"name"` Name string
} }
type OwnershipPredecessor struct { type OwnershipPredecessor struct {
ID uint32 `json:"id"` ID uint32
Name string `json:"name"` Name string
Role RoleRef `json:"role"` Role RoleRef
} }
type OwnershipTransfer struct { type OwnershipTransfer struct {
Owner OwnershipSuccessor `json:"owner"` Owner OwnershipSuccessor
FormerOwner OwnershipPredecessor `json:"former_owner"` FormerOwner OwnershipPredecessor
} }
func NewTransferOwnershipUseCase(users user.Repository, roles role.Repository, recorder audit.Recorder) *TransferOwnershipUseCase { func NewTransferOwnershipUseCase(users user.Repository, roles role.Repository, recorder audit.Recorder) *TransferOwnershipUseCase {
@@ -3,7 +3,11 @@ package useCases
import "RenewCMS/internal/domain/invite" import "RenewCMS/internal/domain/invite"
func findManageableInvite(inviteRepository invite.Repository, id uint32) (invite.Invite, error) { func findManageableInvite(inviteRepository invite.Repository, id uint32) (invite.Invite, error) {
for _, existing := range inviteRepository.GetAll() { invites, err := inviteRepository.GetAll()
if err != nil {
return invite.Invite{}, err
}
for _, existing := range invites {
if existing.ID != id { if existing.ID != id {
continue continue
} }
+4 -4
View File
@@ -11,10 +11,10 @@ type InviteView struct {
} }
type RoleRef struct { type RoleRef struct {
ID uint32 `json:"id"` ID uint32
Name string `json:"name"` Name string
BuiltIn bool `json:"built_in"` BuiltIn bool
Locked bool `json:"locked"` Locked bool
} }
func NewRoleRef(found role.Role) RoleRef { func NewRoleRef(found role.Role) RoleRef {
+5 -5
View File
@@ -9,15 +9,15 @@ import (
) )
type RoleEditor struct { type RoleEditor struct {
ID uint32 `json:"id"` ID uint32
Name string `json:"name"` Name string
} }
type RoleView struct { type RoleView struct {
role.Role role.Role
Members int `json:"members"` Members int
UpdatedAt *time.Time `json:"updated_at"` UpdatedAt *time.Time
UpdatedBy *RoleEditor `json:"updated_by"` UpdatedBy *RoleEditor
} }
type roleViewer struct { type roleViewer struct {
+3 -3
View File
@@ -43,10 +43,10 @@ type atomicInviteRepoDecorator struct {
afterGetAll func() afterGetAll func()
} }
func (d *atomicInviteRepoDecorator) GetAll() []invite.Invite { func (d *atomicInviteRepoDecorator) GetAll() ([]invite.Invite, error) {
all := d.Repository.GetAll() all, err := d.Repository.GetAll()
d.afterGetAll() d.afterGetAll()
return all return all, err
} }
type atomicLookupBarrier struct { type atomicLookupBarrier struct {
-24
View File
@@ -50,39 +50,15 @@ func routerRoutes(t *testing.T) map[string]bool {
} }
var routesNotYetInTheCatalogue = []string{ var routesNotYetInTheCatalogue = []string{
"DELETE /v1/content-type/{key}",
"DELETE /v1/content-type/{key}/entry/{id}",
"DELETE /v1/image-variants/{name}",
"DELETE /v1/media/{id}/variants/{name}",
"DELETE /v1/role/{id}", "DELETE /v1/role/{id}",
"GET /v1/builtin-type",
"GET /v1/builtin-type/{key}",
"GET /v1/content-type",
"GET /v1/content-type/{key}",
"GET /v1/content-type/{key}/entry",
"GET /v1/content-type/{key}/entry/{id}",
"GET /v1/image-variants",
"GET /v1/image-variants/cache",
"GET /v1/media/{id}/variants",
"GET /v1/user/lookup", "GET /v1/user/lookup",
"GET /v1/permission", "GET /v1/permission",
"GET /v1/role", "GET /v1/role",
"GET /v1/role/{id}", "GET /v1/role/{id}",
"PATCH /v1/image-variants/{name}",
"POST /v1/content-type",
"POST /v1/content-type/{key}/entry",
"POST /v1/image-variants",
"POST /v1/image-variants/cache/purge",
"POST /v1/image-variants/{name}/purge",
"POST /v1/media/{id}/variants",
"POST /v1/media/{id}/variants/{name}",
"POST /v1/role", "POST /v1/role",
"POST /v1/role/{id}/reassign", "POST /v1/role/{id}/reassign",
"POST /v1/role/{id}/reset", "POST /v1/role/{id}/reset",
"POST /v1/site/transfer-ownership", "POST /v1/site/transfer-ownership",
"PUT /v1/content-type/{key}",
"PUT /v1/content-type/{key}/entry/{id}",
"PUT /v1/image-variants/{name}",
"PUT /v1/role/{id}", "PUT /v1/role/{id}",
"PUT /v1/user/{id}/role", "PUT /v1/user/{id}/role",
} }
+21 -2
View File
@@ -92,6 +92,9 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
if filters.SiteID != nil && a.SiteID != *filters.SiteID { if filters.SiteID != nil && a.SiteID != *filters.SiteID {
continue continue
} }
if filters.VisibleTo != nil && !a.IsOnline && a.CreatedBy != *filters.VisibleTo {
continue
}
if filters.Status == article.StatusOnline && !a.IsOnline { if filters.Status == article.StatusOnline && !a.IsOnline {
continue continue
} }
@@ -132,6 +135,22 @@ func (m *mockArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, er
return counts, nil return counts, nil
} }
func (m *mockArticleRepo) CountVisibleTo(userID uint32) (article.Counts, error) {
if err := m.fail.trigger("CountVisibleTo"); err != nil {
return article.Counts{}, err
}
counts := article.Counts{}
for _, a := range m.articles {
if a.IsOnline {
counts.Online++
} else if a.CreatedBy == userID {
counts.Draft++
}
}
counts.All = counts.Online + counts.Draft
return counts, nil
}
func articleSortsBefore(left, right article.Article, order article.Sort) bool { func articleSortsBefore(left, right article.Article, order article.Sort) bool {
if order.Field == "" { if order.Field == "" {
order = article.DefaultSort() order = article.DefaultSort()
@@ -195,12 +214,12 @@ func (m *mockArticleRepo) GetByName(name string) (article.Article, error) {
return article.Article{}, article.ErrArticleNotFound return article.Article{}, article.ErrArticleNotFound
} }
func (m *mockArticleRepo) GetAll() []article.Article { func (m *mockArticleRepo) GetAll() ([]article.Article, error) {
result := make([]article.Article, 0) result := make([]article.Article, 0)
for _, id := range m.sortedIds() { for _, id := range m.sortedIds() {
result = append(result, m.articles[id]) result = append(result, m.articles[id])
} }
return result return result, nil
} }
func (m *mockArticleRepo) slugUsedOn(siteID uint32, slug string, exceptID uint32) bool { func (m *mockArticleRepo) slugUsedOn(siteID uint32, slug string, exceptID uint32) bool {
+1 -1
View File
@@ -146,7 +146,7 @@ func (m *mockEntryRepo) ListPage(filters entry.Filters) (entry.PageResult, error
} }
matching := []entry.Entry{} matching := []entry.Entry{}
for _, stored := range m.entries { for _, stored := range m.entries {
if stored.ContentTypeID == filters.ContentTypeID { if stored.ContentTypeID == filters.ContentTypeID && (filters.CreatedBy == nil || stored.CreatedBy == *filters.CreatedBy) {
matching = append(matching, stored) matching = append(matching, stored)
} }
} }
+74
View File
@@ -0,0 +1,74 @@
package test
import (
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"testing"
"time"
)
func TestGetAllFailuresReachTheCaller(t *testing.T) {
boom := errors.New("storage down")
t.Run("registration status answers 500 instead of reporting open", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.fail.failNextWith("GetAll", boom)
router, _ := newRegistrationStatusRouter(userRepo, newMockMailRepo())
rec := registrationStatusRequest(router, nil)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
})
t.Run("IsRegistrationOpen returns the error", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.fail.failNextWith("GetAll", boom)
uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
if _, err := uc.IsRegistrationOpen(); !errors.Is(err, boom) {
t.Errorf("err = %v, want %v", err, boom)
}
})
t.Run("GetInvite returns the error instead of an invalid invite", func(t *testing.T) {
invites := newInviteFakeRepo()
invites.fail.failNextWith("GetAll", boom)
uc := useCases.NewGetInviteUseCase(invites, &stubHasher{})
if _, err := uc.GetInvite("code"); !errors.Is(err, boom) {
t.Errorf("err = %v, want %v", err, boom)
}
})
t.Run("ListUsers returns the error", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.fail.failNextWith("GetAll", boom)
if _, err := useCases.NewListUsersUseCase(userRepo).ListUsers(); !errors.Is(err, boom) {
t.Errorf("err = %v, want %v", err, boom)
}
})
t.Run("retention stops on a user listing failure", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.fail.failNextWith("GetAll", boom)
uc := useCases.NewEnforceRetentionUseCase(userRepo, newInviteFakeRepo(), &memorySessionRepo{})
if _, err := uc.Tick(time.Now()); !errors.Is(err, boom) {
t.Errorf("err = %v, want %v", err, boom)
}
})
t.Run("retention stops on an invite listing failure", func(t *testing.T) {
invites := newInviteFakeRepo()
invites.fail.failNextWith("GetAll", boom)
uc := useCases.NewEnforceRetentionUseCase(newMockUserRepo(), invites, &memorySessionRepo{})
if _, err := uc.Tick(time.Now()); !errors.Is(err, boom) {
t.Errorf("err = %v, want %v", err, boom)
}
})
}
+5 -2
View File
@@ -52,7 +52,10 @@ func (m *inviteFakeRepo) Create(i invite.Invite) (invite.Invite, error) {
return m.seed(i), nil return m.seed(i), nil
} }
func (m *inviteFakeRepo) GetAll() []invite.Invite { func (m *inviteFakeRepo) GetAll() ([]invite.Invite, error) {
if err := m.fail.trigger("GetAll"); err != nil {
return nil, err
}
ids := make([]uint32, 0, len(m.invites)) ids := make([]uint32, 0, len(m.invites))
for id := range m.invites { for id := range m.invites {
ids = append(ids, id) ids = append(ids, id)
@@ -62,7 +65,7 @@ func (m *inviteFakeRepo) GetAll() []invite.Invite {
for _, id := range ids { for _, id := range ids {
result = append(result, m.invites[id]) result = append(result, m.invites[id])
} }
return result return result, nil
} }
func (m *inviteFakeRepo) Delete(id uint32) error { func (m *inviteFakeRepo) Delete(id uint32) error {
+4 -3
View File
@@ -59,8 +59,9 @@ func TestMySQLMigration(t *testing.T) {
} }
} }
binaryTables := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_collation = 'utf8mb4_bin'") binaryTables := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_collation = 'utf8mb4_bin'")
if binaryTables != 6 { allTables := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'")
t.Errorf("every table must use the utf8mb4_bin collation, got %d of 6", binaryTables) if binaryTables != allTables {
t.Errorf("every table must use the utf8mb4_bin collation, got %d of %d", binaryTables, allTables)
} }
}) })
@@ -247,7 +248,7 @@ func TestMySQLBackup(t *testing.T) {
if line := firstDifferingLine(withoutRedundantCharset(dump), withoutRedundantCharset(string(restoredContent))); line != "" { if line := firstDifferingLine(withoutRedundantCharset(dump), withoutRedundantCharset(string(restoredContent))); line != "" {
t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line) t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line)
} }
restoredUsers := newContractRepositories(restored.DB).users.GetAll() restoredUsers := allOf(newContractRepositories(restored.DB).users.GetAll())
if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" { if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" {
t.Errorf("unexpected restored users %+v", restoredUsers) t.Errorf("unexpected restored users %+v", restoredUsers)
} }
@@ -0,0 +1,111 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"testing"
)
func TestArticleRepositoryVisibilityFilterOnSQLite(t *testing.T) {
db := openContractSQLite(t)
repo := persistence.NewArticleRepository(db)
defaultSite, err := persistence.NewSiteRepository(db).GetDefault()
if err != nil {
t.Fatal(err)
}
create := func(owner uint32, online bool) {
t.Helper()
record := articleOn(defaultSite.ID)
record.CreatedBy = owner
record.IsOnline = online
if _, err := repo.Create(record); err != nil {
t.Fatal(err)
}
}
create(1, true)
create(1, false)
create(2, true)
create(2, false)
create(2, false)
one := uint32(1)
t.Run("FindPage keeps online articles and the viewer's drafts", func(t *testing.T) {
found, err := repo.FindPage(article.Filters{VisibleTo: &one, Unpaged: true})
if err != nil {
t.Fatal(err)
}
if found.Total != 3 || len(found.Items) != 3 {
t.Fatalf("total = %d, items = %d, want 3", found.Total, len(found.Items))
}
for _, item := range found.Items {
if !item.IsOnline && item.CreatedBy != one {
t.Errorf("leaked draft %+v", item)
}
}
})
t.Run("FindPage pages the filtered set", func(t *testing.T) {
found, err := repo.FindPage(article.Filters{VisibleTo: &one, Page: 2, Size: 2})
if err != nil {
t.Fatal(err)
}
if found.Total != 3 || len(found.Items) != 1 {
t.Fatalf("total = %d, items = %d, want 3 and 1", found.Total, len(found.Items))
}
})
t.Run("FindPage combines the filter with the draft status", func(t *testing.T) {
found, err := repo.FindPage(article.Filters{VisibleTo: &one, Status: article.StatusDraft, Unpaged: true})
if err != nil {
t.Fatal(err)
}
if found.Total != 1 || found.Items[0].CreatedBy != one {
t.Fatalf("found = %+v", found)
}
})
t.Run("CountVisibleTo counts online articles and the viewer's drafts", func(t *testing.T) {
counts, err := repo.CountVisibleTo(one)
if err != nil {
t.Fatal(err)
}
if counts != (article.Counts{All: 3, Online: 2, Draft: 1}) {
t.Fatalf("counts = %+v", counts)
}
})
}
func TestEntryRepositoryCreatedByFilterOnSQLite(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
types := persistence.NewContentTypeRepository(db)
entries := persistence.NewEntryRepository(db)
created, err := types.Create(contenttype.ContentType{Key: "notes", Name: "Notes", Fields: []contenttype.Field{{Key: "flag", Label: "Flag", Type: contenttype.TypeBoolean}}})
if err != nil {
t.Fatal(err)
}
for _, owner := range []uint32{1, 2, 1, 2, 1} {
if _, err := entries.Create(entry.Entry{ContentTypeID: created.ID, Data: map[string]any{}, CreatedBy: owner, UpdatedBy: owner}); err != nil {
t.Fatal(err)
}
}
one := uint32(1)
found, err := entries.ListPage(entry.Filters{ContentTypeID: created.ID, CreatedBy: &one, Page: 1, Size: 2, Sort: entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc}})
if err != nil {
t.Fatal(err)
}
if found.Total != 3 || len(found.Items) != 2 {
t.Fatalf("total = %d, items = %d, want 3 and 2", found.Total, len(found.Items))
}
for _, item := range found.Items {
if item.CreatedBy != one {
t.Errorf("leaked entry %+v", item)
}
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ func TestEnforceRetentionUseCase(t *testing.T) {
if _, removed := w.users.users[2]; removed { if _, removed := w.users.users[2]; removed {
t.Error("the abandoned account must be removed") t.Error("the abandoned account must be removed")
} }
remaining := w.invites.GetAll() remaining := allOf(w.invites.GetAll())
if len(remaining) != 1 || remaining[0].Email != "pending@example.com" { if len(remaining) != 1 || remaining[0].Email != "pending@example.com" {
t.Errorf("expected only the pending invite kept, got %+v", remaining) t.Errorf("expected only the pending invite kept, got %+v", remaining)
} }
+4 -3
View File
@@ -185,7 +185,7 @@ func TestPostgresMigration(t *testing.T) {
}) })
t.Run("a legacy users.role column is backfilled into role_id and the invites get Admin", func(t *testing.T) { t.Run("a legacy users.role column is backfilled into role_id and the invites get Admin", func(t *testing.T) {
_, _, db := migratedPostgres(t) server, config, db := migratedPostgres(t)
addLegacyRoleColumn(t, db) addLegacyRoleColumn(t, db)
for _, name := range []string{"first", "second", "third"} { for _, name := range []string{"first", "second", "third"} {
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example") insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example")
@@ -193,6 +193,7 @@ func TestPostgresMigration(t *testing.T) {
t.Fatalf("insert %s: %v", name, insert.Error) t.Fatalf("insert %s: %v", name, insert.Error)
} }
} }
db = server.connect(t, config).DB
if err := database.Migrate(db, migrationPublicURL); err != nil { if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
@@ -241,7 +242,7 @@ func TestPostgresTimesInLists(t *testing.T) {
mustCreateUser(t, repos, user) mustCreateUser(t, repos, user)
} }
users := repos.users.GetAll() users := allOf(repos.users.GetAll())
if len(users) != 2 { if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users)) t.Fatalf("expected 2 users, got %d", len(users))
@@ -402,7 +403,7 @@ func TestPostgresBackup(t *testing.T) {
t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line) t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line)
} }
restoredRepos := newContractRepositories(restored.DB) restoredRepos := newContractRepositories(restored.DB)
restoredUsers := restoredRepos.users.GetAll() restoredUsers := allOf(restoredRepos.users.GetAll())
if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" { if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" {
t.Errorf("unexpected restored users %+v", restoredUsers) t.Errorf("unexpected restored users %+v", restoredUsers)
} }
@@ -164,7 +164,7 @@ func TestRegistrationStatusEndpointHttp(t *testing.T) {
mailRepo := newMockMailRepo() mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder) uc := useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
if uc.IsRegistrationOpen() { if open, openErr := uc.IsRegistrationOpen(); openErr != nil || open {
t.Fatalf("expected IsRegistrationOpen to return false") t.Fatalf("expected IsRegistrationOpen to return false")
} }
+10 -3
View File
@@ -207,7 +207,7 @@ func runRepositoryContract(t *testing.T, backend contractBackend) {
if err == nil { if err == nil {
t.Error("expected an error for the duplicate email") t.Error("expected an error for the duplicate email")
} }
if all := repos.users.GetAll(); len(all) != 1 || all[0].Username != "first" { if all := allOf(repos.users.GetAll()); len(all) != 1 || all[0].Username != "first" {
t.Errorf("nothing may be stored for the failed create, got %+v", all) t.Errorf("nothing may be stored for the failed create, got %+v", all)
} }
}}, }},
@@ -231,7 +231,7 @@ func runRepositoryContract(t *testing.T, backend contractBackend) {
if !errors.Is(secondErr, domainInvite.ErrInviteNotFound) { if !errors.Is(secondErr, domainInvite.ErrInviteNotFound) {
t.Errorf("expected ErrInviteNotFound, got %v", secondErr) t.Errorf("expected ErrInviteNotFound, got %v", secondErr)
} }
if len(repos.users.GetAll()) != 1 { if len(allOf(repos.users.GetAll())) != 1 {
t.Errorf("only the first accept may create a user") t.Errorf("only the first accept may create a user")
} }
}}, }},
@@ -244,7 +244,7 @@ func runRepositoryContract(t *testing.T, backend contractBackend) {
if err == nil { if err == nil {
t.Fatal("expected the taken username to be refused") t.Fatal("expected the taken username to be refused")
} }
invites := repos.invites.GetAll() invites := allOf(repos.invites.GetAll())
if len(invites) != 1 || invites[0].AcceptedAt != nil { if len(invites) != 1 || invites[0].AcceptedAt != nil {
t.Errorf("the invite must stay pending, got %+v", invites) t.Errorf("the invite must stay pending, got %+v", invites)
} }
@@ -366,3 +366,10 @@ func TestSQLiteRepositoryContract(t *testing.T) {
func TestMySQLRepositoryContract(t *testing.T) { func TestMySQLRepositoryContract(t *testing.T) {
runRepositoryContract(t, contractBackend{name: "mysql", open: openContractMySQL}) runRepositoryContract(t, contractBackend{name: "mysql", open: openContractMySQL})
} }
func allOf[T any](all []T, err error) []T {
if err != nil {
panic(err)
}
return all
}
+6 -6
View File
@@ -165,20 +165,20 @@ func TestArticleRepositoryWritesOnSQLite(t *testing.T) {
} }
} }
if all := repo.GetAll(); len(all) != 2 { if all := allOf(repo.GetAll()); len(all) != 2 {
t.Errorf("articles = %+v", all) t.Errorf("articles = %+v", all)
} }
}) })
t.Run("GetAll returns an empty list when the query fails", func(t *testing.T) { t.Run("GetAll returns the error when the query fails", func(t *testing.T) {
repo, db, _ := setup(t) repo, db, _ := setup(t)
fault := injectDatabaseFault(db, "fault:getall", faultOnQuery) fault := injectDatabaseFault(db, "fault:getall", faultOnQuery)
fault.armed.Store(true) fault.armed.Store(true)
all := repo.GetAll() all, err := repo.GetAll()
if all == nil || len(all) != 0 { if err == nil || len(all) != 0 {
t.Errorf("articles = %#v", all) t.Errorf("articles = %#v, err = %v", all, err)
} }
}) })
@@ -436,7 +436,7 @@ func TestSiteDeletionIsCheckedInsideTheTransaction(t *testing.T) {
_, updateErr := articles.Update(created.ID, article.Update{SiteID: &shop.ID}) _, updateErr := articles.Update(created.ID, article.Update{SiteID: &shop.ID})
unchanged, _ := articles.Get(created.ID) unchanged, _ := articles.Get(created.ID)
if !errors.Is(createErr, site.ErrSiteNotFound) || !errors.Is(updateErr, site.ErrSiteNotFound) || unchanged.SiteID != blog.ID || len(articles.GetAll()) != 1 { if !errors.Is(createErr, site.ErrSiteNotFound) || !errors.Is(updateErr, site.ErrSiteNotFound) || unchanged.SiteID != blog.ID || len(allOf(articles.GetAll())) != 1 {
t.Errorf("create %v, update %v, site %d", createErr, updateErr, unchanged.SiteID) t.Errorf("create %v, update %v, site %d", createErr, updateErr, unchanged.SiteID)
} }
}) })
@@ -113,7 +113,7 @@ func TestUnverifiedUsersAndReregistrationRepository(t *testing.T) {
t.Fatal("expected Create to fail because the username is still in use") t.Fatal("expected Create to fail because the username is still in use")
} }
users := repo.GetAll() users := allOf(repo.GetAll())
count := 0 count := 0
for _, u := range users { for _, u := range users {
if u.Username == "alice" { if u.Username == "alice" {
+1 -1
View File
@@ -549,7 +549,7 @@ func TestListUsersReturnsEveryUser(t *testing.T) {
repo.users[2] = user.User{ID: 2} repo.users[2] = user.User{ID: 2}
uc := useCases.NewListUsersUseCase(repo) uc := useCases.NewListUsersUseCase(repo)
result := uc.ListUsers() result, _ := uc.ListUsers()
if len(result) != 2 { if len(result) != 2 {
t.Fatalf("expected 2 users, got %d", len(result)) t.Fatalf("expected 2 users, got %d", len(result))
+5 -2
View File
@@ -115,13 +115,16 @@ func (m *mockUserRepo) GetByEmail(email string) (user.User, error) {
return user.User{}, user.ErrUserNotFound return user.User{}, user.ErrUserNotFound
} }
func (m *mockUserRepo) GetAll() []user.User { func (m *mockUserRepo) GetAll() ([]user.User, error) {
m.getAllCalls++ m.getAllCalls++
if err := m.fail.trigger("GetAll"); err != nil {
return nil, err
}
result := make([]user.User, 0) result := make([]user.User, 0)
for _, id := range m.sortedIds() { for _, id := range m.sortedIds() {
result = append(result, m.users[id]) result = append(result, m.users[id])
} }
return result return result, nil
} }
func (m *mockUserRepo) FindByIDs(ids []uint32) ([]user.User, error) { func (m *mockUserRepo) FindByIDs(ids []uint32) ([]user.User, error) {
+1 -1
View File
@@ -260,7 +260,7 @@ type usersAPIGetAllHookRepo struct {
hook func() hook func()
} }
func (r *usersAPIGetAllHookRepo) GetAll() []user.User { func (r *usersAPIGetAllHookRepo) GetAll() ([]user.User, error) {
if r.hook != nil { if r.hook != nil {
hook := r.hook hook := r.hook
r.hook = nil r.hook = nil
+1 -1
View File
@@ -167,7 +167,7 @@ func TestUsersAPIInviteRepository(t *testing.T) {
if err != nil || renewed.RoleID != writer { if err != nil || renewed.RoleID != writer {
t.Fatalf("unexpected %+v, %v", renewed, err) t.Fatalf("unexpected %+v, %v", renewed, err)
} }
if all := repo.GetAll(); len(all) != 1 || all[0].RoleID != writer { if all := allOf(repo.GetAll()); len(all) != 1 || all[0].RoleID != writer {
t.Errorf("unexpected %+v", all) t.Errorf("unexpected %+v", all)
} }
}) })
+2 -4
View File
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { t } from '@/i18n'
import { useMediaQuery } from '@vueuse/core'
import { computed } from 'vue' import { computed } from 'vue'
import ApiSearchPanel from '@/components/ApiSearchPanel.vue' import ApiSearchPanel from '@/components/ApiSearchPanel.vue'
import type { SearchEntry, SearchIndex } from '@/lib/apiSearch' import type { SearchEntry, SearchIndex } from '@/lib/apiSearch'
import { useIsDesktop } from '@/composables/useIsDesktop'
defineProps<{ defineProps<{
index: SearchIndex index: SearchIndex
@@ -12,10 +12,8 @@ defineProps<{
const emit = defineEmits<{ select: [entry: SearchEntry] }>() const emit = defineEmits<{ select: [entry: SearchEntry] }>()
const DESKTOP_QUERY = '(min-width: 64rem)'
const open = defineModel<boolean>('open', { required: true }) const open = defineModel<boolean>('open', { required: true })
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop('64rem')
const contentClass = computed(() => const contentClass = computed(() =>
isDesktop.value isDesktop.value
+2 -4
View File
@@ -2,11 +2,9 @@
import { t, formatNumber } from '@/i18n' import { t, formatNumber } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { pageItems, rangeFor } from '@/lib/pagination' import { pageItems, rangeFor } from '@/lib/pagination'
import PageSizeSelect from '@/components/PageSizeSelect.vue' import PageSizeSelect from '@/components/PageSizeSelect.vue'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const props = defineProps<{ const props = defineProps<{
page: number page: number
@@ -23,7 +21,7 @@ const emit = defineEmits<{
'update:size': [size: number] 'update:size': [size: number]
}>() }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const busy = computed(() => props.loadingPage !== null) const busy = computed(() => props.loadingPage !== null)
const atStart = computed(() => busy.value || props.page <= 1) const atStart = computed(() => busy.value || props.page <= 1)
+2 -3
View File
@@ -2,13 +2,12 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, ref, useId } from 'vue' import { computed, nextTick, ref, useId } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import AuthorAvatar from '@/components/AuthorAvatar.vue' import AuthorAvatar from '@/components/AuthorAvatar.vue'
import type { Byline } from '@/composables/useArticles' import type { Byline } from '@/composables/useArticles'
import { moveItem } from '@/lib/arrayMove' import { moveItem } from '@/lib/arrayMove'
import { insertionTarget } from '@/lib/articleMeta' import { insertionTarget } from '@/lib/articleMeta'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 64rem)'
const DESKTOP_AVATAR = 22 const DESKTOP_AVATAR = 22
const MOBILE_AVATAR = 28 const MOBILE_AVATAR = 28
@@ -19,7 +18,7 @@ const props = withDefaults(defineProps<{ authors: Byline[]; hintHidden?: boolean
const emit = defineEmits<{ 'update:authors': [authors: Byline[]] }>() const emit = defineEmits<{ 'update:authors': [authors: Byline[]] }>()
const labelId = useId() const labelId = useId()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop('64rem')
const list = ref<HTMLElement | null>(null) const list = ref<HTMLElement | null>(null)
const working = ref<Byline[] | null>(null) const working = ref<Byline[] | null>(null)
const grabbed = ref<number | null>(null) const grabbed = ref<number | null>(null)
+2 -3
View File
@@ -2,13 +2,12 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { ref } from 'vue' import { ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import ColumnPickerList from '@/components/ColumnPickerList.vue' import ColumnPickerList from '@/components/ColumnPickerList.vue'
import type { BuiltinField } from '@/lib/contentTypes' import type { BuiltinField } from '@/lib/contentTypes'
import { COLUMN_CAP } from '@/lib/entryColumns' import { COLUMN_CAP } from '@/lib/entryColumns'
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi' import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const FOCUS_RING = const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary' 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const TRIGGER = `h-11 w-11 justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 md:h-8 md:w-auto md:px-3 ${FOCUS_RING}` const TRIGGER = `h-11 w-11 justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 md:h-8 md:w-auto md:px-3 ${FOCUS_RING}`
@@ -17,7 +16,7 @@ defineProps<{ fields: BuiltinField[]; keys: string[]; atCap: boolean; disabled?:
const emit = defineEmits<{ toggle: [key: string, on: boolean]; reset: [] }>() const emit = defineEmits<{ toggle: [key: string, on: boolean]; reset: [] }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const open = ref(false) const open = ref(false)
</script> </script>
+2 -4
View File
@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { t } from '@/i18n'
import { useMediaQuery } from '@vueuse/core'
import ColumnPicker from '@/components/ColumnPicker.vue' import ColumnPicker from '@/components/ColumnPicker.vue'
import SortSelect from '@/components/SortSelect.vue' import SortSelect from '@/components/SortSelect.vue'
import type { BuiltinField } from '@/lib/contentTypes' import type { BuiltinField } from '@/lib/contentTypes'
import type { EntrySortField } from '@/lib/entryListQuery' import type { EntrySortField } from '@/lib/entryListQuery'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
defineProps<{ defineProps<{
sort: string sort: string
@@ -25,7 +23,7 @@ const emit = defineEmits<{
reset: [] reset: []
}>() }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
</script> </script>
<template> <template>
+2 -3
View File
@@ -2,11 +2,10 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { ref } from 'vue' import { ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue' import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
import type { DirectoryMenuItem } from '@/lib/directoryMenu' import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const DEFAULT_SKELETON_ROWS = 3 const DEFAULT_SKELETON_ROWS = 3
const props = withDefaults( const props = withDefaults(
@@ -31,7 +30,7 @@ const props = withDefaults(
const emit = defineEmits<{ open: [item: T] }>() const emit = defineEmits<{ open: [item: T] }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const openMenuId = ref<number | null>(null) const openMenuId = ref<number | null>(null)
function setMenuOpen(item: T, isOpen: boolean) { function setMenuOpen(item: T, isOpen: boolean) {
+2 -3
View File
@@ -2,12 +2,11 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, ref, useId } from 'vue' import { computed, nextTick, ref, useId } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation' import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { DirectoryNotice } from '@/lib/directoryTypes' import type { DirectoryNotice } from '@/lib/directoryTypes'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const AUTOFOCUS_SELECTOR = '[data-autofocus]' const AUTOFOCUS_SELECTOR = '[data-autofocus]'
const INVALID_SELECTOR = '[aria-invalid="true"]' const INVALID_SELECTOR = '[aria-invalid="true"]'
@@ -38,7 +37,7 @@ const emit = defineEmits<{ close: []; submit: [] }>()
const open = defineModel<boolean>('open', { default: true }) const open = defineModel<boolean>('open', { default: true })
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const formId = useId() const formId = useId()
const form = ref<{ $el: HTMLElement } | null>(null) const form = ref<{ $el: HTMLElement } | null>(null)
const side = computed(() => (isDesktop.value ? 'right' : 'bottom')) const side = computed(() => (isDesktop.value ? 'right' : 'bottom'))
+3 -3
View File
@@ -2,9 +2,9 @@
import { t, formatNumber } from '@/i18n' import { t, formatNumber } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import { useElementBounding, useMediaQuery } from '@vueuse/core' import { useElementBounding } from '@vueuse/core'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const CONTENT_SELECTOR = '#dashboard-content' const CONTENT_SELECTOR = '#dashboard-content'
defineProps<{ defineProps<{
@@ -18,7 +18,7 @@ const emit = defineEmits<{
clear: [] clear: []
}>() }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const content = useElementBounding(() => document.querySelector<HTMLElement>(CONTENT_SELECTOR)) const content = useElementBounding(() => document.querySelector<HTMLElement>(CONTENT_SELECTOR))
const centre = computed(() => `${content.left.value + content.width.value / 2}px`) const centre = computed(() => `${content.left.value + content.width.value / 2}px`)
</script> </script>
+2 -3
View File
@@ -2,7 +2,6 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue' import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
import MediaVariantsTab from '@/components/variants/MediaVariantsTab.vue' import MediaVariantsTab from '@/components/variants/MediaVariantsTab.vue'
@@ -19,8 +18,8 @@ import { usageSplitOf } from '@/lib/mediaUsage'
import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles' import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles'
import { useCanManageVariants } from '@/composables/useCanManageVariants' import { useCanManageVariants } from '@/composables/useCanManageVariants'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const ACTION_BUTTON = const ACTION_BUTTON =
'h-11 justify-center border border-accented bg-default px-3 text-sm ring-0 max-md:flex-1 md:h-8' 'h-11 justify-center border border-accented bg-default px-3 text-sm ring-0 max-md:flex-1 md:h-8'
@@ -41,7 +40,7 @@ const emit = defineEmits<{
}>() }>()
const open = defineModel<boolean>('open', { default: false }) const open = defineModel<boolean>('open', { default: false })
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const canManageVariants = useCanManageVariants() const canManageVariants = useCanManageVariants()
const permissions = usePermissionsStore() const permissions = usePermissionsStore()
const canRename = computed(() => permissions.can('media.upload')) const canRename = computed(() => permissions.can('media.upload'))
+2 -3
View File
@@ -2,10 +2,10 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, ref, useId } from 'vue' import { computed, nextTick, ref, useId } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import DirectoryField from '@/components/DirectoryField.vue' import DirectoryField from '@/components/DirectoryField.vue'
import ImageUploader from '@/components/ImageUploader.vue' import ImageUploader from '@/components/ImageUploader.vue'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
import { useIsDesktop } from '@/composables/useIsDesktop'
import { import {
imageButtonName, imageButtonName,
imageMeta, imageMeta,
@@ -15,7 +15,6 @@ import {
type MediaSummary, type MediaSummary,
} from '@/lib/mediaField' } from '@/lib/mediaField'
const DESKTOP_QUERY = '(min-width: 48rem)'
const BUTTON_CLASS = 'h-11 gap-1.5 px-3 text-sm text-highlighted disabled:opacity-40 md:h-8' const BUTTON_CLASS = 'h-11 gap-1.5 px-3 text-sm text-highlighted disabled:opacity-40 md:h-8'
const OUTLINE_CLASS = `${BUTTON_CLASS} border border-accented bg-default ring-0` const OUTLINE_CLASS = `${BUTTON_CLASS} border border-accented bg-default ring-0`
const ROW_BUTTON_CLASS = `${BUTTON_CLASS} max-md:flex-1` const ROW_BUTTON_CLASS = `${BUTTON_CLASS} max-md:flex-1`
@@ -46,7 +45,7 @@ const labelId = `${id}-label`
const helpId = `${id}-help` const helpId = `${id}-help`
const errorId = `${id}-error` const errorId = `${id}-error`
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const pickerOpen = ref(false) const pickerOpen = ref(false)
const hovered = ref(false) const hovered = ref(false)
const announcement = ref('') const announcement = ref('')
+2 -3
View File
@@ -2,7 +2,6 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
import VariantPickerField from '@/components/variants/VariantPickerField.vue' import VariantPickerField from '@/components/variants/VariantPickerField.vue'
import type { LibraryStatus } from '@/composables/useLibraryList' import type { LibraryStatus } from '@/composables/useLibraryList'
@@ -10,9 +9,9 @@ import type { ImageVariant } from '@/composables/useImageVariants'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
import type { FieldPick } from '@/lib/imageChoice' import type { FieldPick } from '@/lib/imageChoice'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat' import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
import { useIsDesktop } from '@/composables/useIsDesktop'
const SEARCH_DEBOUNCE_MS = 120 const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const FIRST_LOAD_SKELETONS_DESKTOP = 8 const FIRST_LOAD_SKELETONS_DESKTOP = 8
const FIRST_LOAD_SKELETONS_MOBILE = 9 const FIRST_LOAD_SKELETONS_MOBILE = 9
const COLUMNS_DESKTOP = 4 const COLUMNS_DESKTOP = 4
@@ -62,7 +61,7 @@ const tileAspect = computed(() =>
const alt = defineModel<string>('alt', { default: '' }) const alt = defineModel<string>('alt', { default: '' })
const variant = defineModel<string>('variant', { default: '' }) const variant = defineModel<string>('variant', { default: '' })
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const grid = ref<HTMLElement | null>(null) const grid = ref<HTMLElement | null>(null)
const searchText = ref(props.query) const searchText = ref(props.query)
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
+2 -3
View File
@@ -2,7 +2,6 @@
import { t, formatNumber } from '@/i18n' import { t, formatNumber } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaVariantsFilter from '@/components/MediaVariantsFilter.vue' import MediaVariantsFilter from '@/components/MediaVariantsFilter.vue'
import type { MediaCounts } from '@/composables/useMedia' import type { MediaCounts } from '@/composables/useMedia'
import type { import type {
@@ -13,9 +12,9 @@ import type {
MediaView, MediaView,
} from '@/lib/mediaQuery' } from '@/lib/mediaQuery'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles' import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
import { useIsDesktop } from '@/composables/useIsDesktop'
const SEARCH_DEBOUNCE_MS = 120 const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const SORT_LABELS: Record<MediaSort, string> = { const SORT_LABELS: Record<MediaSort, string> = {
get newest() { get newest() {
@@ -71,7 +70,7 @@ const emit = defineEmits<{
purge: [] purge: []
}>() }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const searchText = ref(props.controls.q) const searchText = ref(props.controls.q)
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
+3 -3
View File
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, ref, watch } from 'vue' import { computed, inject, ref, watch } from 'vue'
import { useEventListener, useMediaQuery } from '@vueuse/core' import { useEventListener } from '@vueuse/core'
import { MEDIA_USAGE_KEY } from '@/lib/mediaUsageContext' import { MEDIA_USAGE_KEY } from '@/lib/mediaUsageContext'
import { tooltipLines } from '@/lib/mediaUsage' import { tooltipLines } from '@/lib/mediaUsage'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const TOOLTIP_UI = { const TOOLTIP_UI = {
content: content:
'h-auto flex-col items-start gap-0 bg-inverted px-2 py-1.5 text-xs/4.5 text-inverted shadow-(--shadow-popover) ring-0', 'h-auto flex-col items-start gap-0 bg-inverted px-2 py-1.5 text-xs/4.5 text-inverted shadow-(--shadow-popover) ring-0',
@@ -17,7 +17,7 @@ const props = defineProps<{
}>() }>()
const usage = inject(MEDIA_USAGE_KEY, null) const usage = inject(MEDIA_USAGE_KEY, null)
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const hovered = ref(false) const hovered = ref(false)
const dismissed = ref(false) const dismissed = ref(false)
+2 -4
View File
@@ -2,7 +2,6 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import ApiPreview from '@/components/ApiPreview.vue' import ApiPreview from '@/components/ApiPreview.vue'
import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue' import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue'
import BuilderActions from '@/components/BuilderActions.vue' import BuilderActions from '@/components/BuilderActions.vue'
@@ -22,12 +21,11 @@ import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
import { sampleJson } from '@/lib/schemaBuilderSample' import { sampleJson } from '@/lib/schemaBuilderSample'
import { entriesNotice } from '@/lib/schemaEntries' import { entriesNotice } from '@/lib/schemaEntries'
import { formatClock } from '@/lib/updateFormat' import { formatClock } from '@/lib/updateFormat'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const props = defineProps<{ edit?: EditSource }>() const props = defineProps<{ edit?: EditSource }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const builder = useSchemaBuilder(props.edit) const builder = useSchemaBuilder(props.edit)
const { form } = builder const { form } = builder
const remove = props.edit ? useSchemaDelete(() => props.edit!) : undefined const remove = props.edit ? useSchemaDelete(() => props.edit!) : undefined
+3 -3
View File
@@ -3,7 +3,7 @@ import AppSwitch from '@/components/AppSwitch.vue'
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { createReusableTemplate, useMediaQuery } from '@vueuse/core' import { createReusableTemplate } from '@vueuse/core'
import DirectoryField from '@/components/DirectoryField.vue' import DirectoryField from '@/components/DirectoryField.vue'
import DirectoryPanel from '@/components/DirectoryPanel.vue' import DirectoryPanel from '@/components/DirectoryPanel.vue'
import FallbackChip from '@/components/FallbackChip.vue' import FallbackChip from '@/components/FallbackChip.vue'
@@ -34,8 +34,8 @@ import {
type SeoPreviewInput, type SeoPreviewInput,
} from '@/lib/seoPreview' } from '@/lib/seoPreview'
import type { SeoFocus, SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes' import type { SeoFocus, SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const PANEL_FIELDS: SuggestionField[] = [ const PANEL_FIELDS: SuggestionField[] = [
'seo_title', 'seo_title',
'seo_description', 'seo_description',
@@ -79,7 +79,7 @@ const [DefineFields, Fields] = createReusableTemplate()
const [DefinePreviews, Previews] = createReusableTemplate() const [DefinePreviews, Previews] = createReusableTemplate()
const [DefinePreviewButton, PreviewButton] = createReusableTemplate() const [DefinePreviewButton, PreviewButton] = createReusableTemplate()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const layout = ref<HTMLElement | null>(null) const layout = ref<HTMLElement | null>(null)
const focusTarget = ref<SeoFocus | null>(props.focus) const focusTarget = ref<SeoFocus | null>(props.focus)
const device = ref<PreviewDevice>('desktop') const device = ref<PreviewDevice>('desktop')
+2 -3
View File
@@ -2,12 +2,11 @@
import { locale, t } from '@/i18n' import { locale, t } from '@/i18n'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu' import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
import { chooseLanguage, languageOptions } from '@/composables/useLanguage' import { chooseLanguage, languageOptions } from '@/composables/useLanguage'
import { useAccountMenuRadios } from '@/composables/useRadioMenuRoles' import { useAccountMenuRadios } from '@/composables/useRadioMenuRoles'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 64rem)'
const ITEM_CLASS = 'h-8 items-center gap-2.5 px-2 py-0 text-sm font-normal text-highlighted' const ITEM_CLASS = 'h-8 items-center gap-2.5 px-2 py-0 text-sm font-normal text-highlighted'
const CHOICE_CLASS = 'h-8 items-center gap-2.5 px-2 py-0 text-base font-normal text-highlighted' const CHOICE_CLASS = 'h-8 items-center gap-2.5 px-2 py-0 text-base font-normal text-highlighted'
const SECTION_LABEL_CLASS = const SECTION_LABEL_CLASS =
@@ -27,7 +26,7 @@ const emit = defineEmits<{
}>() }>()
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props) const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop('64rem')
const open = ref(false) const open = ref(false)
useAccountMenuRadios(open) useAccountMenuRadios(open)
const chip = ref<{ $el: HTMLElement } | null>(null) const chip = ref<{ $el: HTMLElement } | null>(null)
+2 -4
View File
@@ -1,17 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import ProfileCard from '@/components/ProfileCard.vue' import ProfileCard from '@/components/ProfileCard.vue'
import RoleBadge from '@/components/RoleBadge.vue' import RoleBadge from '@/components/RoleBadge.vue'
import RoleSummaryDialog from '@/components/RoleSummaryDialog.vue' import RoleSummaryDialog from '@/components/RoleSummaryDialog.vue'
import { buildHeldRolePreview } from '@/lib/rolePreview' import { buildHeldRolePreview } from '@/lib/rolePreview'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const permissions = usePermissionsStore() const permissions = usePermissionsStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const summaryOpen = ref(false) const summaryOpen = ref(false)
const model = computed(() => buildHeldRolePreview(permissions.grants)) const model = computed(() => buildHeldRolePreview(permissions.grants))
@@ -2,7 +2,6 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation' import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import VariantFormFields from '@/components/variants/VariantFormFields.vue' import VariantFormFields from '@/components/variants/VariantFormFields.vue'
@@ -19,8 +18,8 @@ import {
type VariantFormValues, type VariantFormValues,
} from '@/lib/variants/variantForm' } from '@/lib/variants/variantForm'
import { useImageVariantsStore } from '@/stores/imageVariants' import { useImageVariantsStore } from '@/stores/imageVariants'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 40rem)'
const CONFLICT = 409 const CONFLICT = 409
const UNPROCESSABLE = 422 const UNPROCESSABLE = 422
const TOAST_STATUSES = [400, 403, 404] const TOAST_STATUSES = [400, 403, 404]
@@ -39,7 +38,7 @@ const emit = defineEmits<{
const store = useImageVariantsStore() const store = useImageVariantsStore()
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
const { announceFailure } = useVariantToasts() const { announceFailure } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop('40rem')
const form = useVariantForm(props.mode, props.initial) const form = useVariantForm(props.mode, props.initial)
const side = computed(() => (isDesktop.value ? 'right' : 'bottom')) const side = computed(() => (isDesktop.value ? 'right' : 'bottom'))
+1 -1
View File
@@ -216,7 +216,7 @@ describe('getMe maps the account fields', () => {
await expect(useApi().getMe()).resolves.toEqual({ await expect(useApi().getMe()).resolves.toEqual({
id: 1, id: 1,
verified: true, verified: true,
access: ownerAccess(), access: { ...ownerAccess(), roleAssignedBy: null },
username: 'marta', username: 'marta',
email: 'marta@renew.example', email: 'marta@renew.example',
displayName: 'Marta Alves', displayName: 'Marta Alves',
+2 -3
View File
@@ -1,6 +1,5 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useContentTypeDetail } from '@/composables/useContentTypeDetail' import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
import { useEntryColumns } from '@/composables/useEntryColumns' import { useEntryColumns } from '@/composables/useEntryColumns'
import { useEntryList } from '@/composables/useEntryList' import { useEntryList } from '@/composables/useEntryList'
@@ -8,8 +7,8 @@ import { resolveContentListState } from '@/lib/contentListState'
import { entryCaption } from '@/lib/entryListQuery' import { entryCaption } from '@/lib/entryListQuery'
import { fieldCountLabel } from '@/lib/schemaRows' import { fieldCountLabel } from '@/lib/schemaRows'
import { HOME_PATH, entryPath, schemaPath } from '@/lib/routePaths' import { HOME_PATH, entryPath, schemaPath } from '@/lib/routePaths'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const DESKTOP_SKELETON_ROWS = 8 const DESKTOP_SKELETON_ROWS = 8
const MOBILE_SKELETON_ROWS = 6 const MOBILE_SKELETON_ROWS = 6
@@ -19,7 +18,7 @@ export function useContentListPage(
canEdit: () => boolean, canEdit: () => boolean,
) { ) {
const router = useRouter() const router = useRouter()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const list = useEntryList(typeKey) const list = useEntryList(typeKey)
const columns = useEntryColumns(typeKey, () => detail.type.value?.fields ?? []) const columns = useEntryColumns(typeKey, () => detail.type.value?.fields ?? [])
+2 -3
View File
@@ -1,6 +1,5 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation' import { useLocalizedToast } from '@/i18n/presentation'
import { useEntryDraft } from '@/composables/useEntryDraft' import { useEntryDraft } from '@/composables/useEntryDraft'
@@ -12,8 +11,8 @@ import { entryTitle } from '@/lib/entryForm'
import type { EntryTarget } from '@/lib/entryEditorState' import type { EntryTarget } from '@/lib/entryEditorState'
import { formatEntryStamp, formatSavedClock } from '@/lib/entryStamp' import { formatEntryStamp, formatSavedClock } from '@/lib/entryStamp'
import { contentPath, entryPath } from '@/lib/routePaths' import { contentPath, entryPath } from '@/lib/routePaths'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const SAVED_TOAST_ID = 'entry-saved' const SAVED_TOAST_ID = 'entry-saved'
const MISSING_NAME = '–' const MISSING_NAME = '–'
@@ -26,7 +25,7 @@ export function useEntryEditorPage(
) { ) {
const router = useRouter() const router = useRouter()
const toast = useLocalizedToast() const toast = useLocalizedToast()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const editor = useEntryDraft(typeKey, target) const editor = useEntryDraft(typeKey, target)
const { detail, load, form } = editor const { detail, load, form } = editor
const names = useEntryNames(() => load.entry.value) const names = useEntryNames(() => load.entry.value)
+7
View File
@@ -0,0 +1,7 @@
import { useMediaQuery } from '@vueuse/core'
export type DesktopBreakpoint = '40rem' | '48rem' | '64rem'
export function useIsDesktop(minWidth: DesktopBreakpoint = '48rem') {
return useMediaQuery(`(min-width: ${minWidth})`)
}
+25
View File
@@ -0,0 +1,25 @@
import { useApi } from '@/composables/useApi'
const JSON_HEADERS = { 'Content-Type': 'application/json' }
export function useProfile() {
const { request } = useApi()
function updateDisplayName(displayName: string) {
return request<{ display_name?: string | null }>('/v1/user/me', {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify({ display_name: displayName }),
})
}
function sendPasswordReset(email: string) {
return request('/v1/auth/password-reset', {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ email }),
})
}
return { updateDisplayName, sendPasswordReset }
}
+8 -3
View File
@@ -42,7 +42,11 @@ beforeEach(() => {
setViewportWidth(DESKTOP_WIDTH) setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia()) setActivePinia(createPinia())
useSessionStore().status = 'ready' useSessionStore().status = 'ready'
request.mockReset().mockResolvedValue([]) request
.mockReset()
.mockImplementation(async (path: string) =>
path === '/v1/image-variants' ? { items: [] } : [],
)
vi.mocked(useApi).mockReturnValue({ vi.mocked(useApi).mockReturnValue({
request, request,
getMe: vi.fn().mockResolvedValue(null), getMe: vi.fn().mockResolvedValue(null),
@@ -50,10 +54,11 @@ beforeEach(() => {
}) })
afterEach(async () => { afterEach(async () => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
wrapper?.unmount() wrapper?.unmount()
wrapper = undefined wrapper = undefined
await flushPromises()
vi.unstubAllGlobals()
vi.restoreAllMocks()
document.body.innerHTML = '' document.body.innerHTML = ''
localStorage.clear() localStorage.clear()
initializeLocale() initializeLocale()
+2 -4
View File
@@ -3,7 +3,6 @@ import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import ActivityCardList from '@/components/ActivityCardList.vue' import ActivityCardList from '@/components/ActivityCardList.vue'
import ActivityChips from '@/components/ActivityChips.vue' import ActivityChips from '@/components/ActivityChips.vue'
import ActivityDetailsPanel from '@/components/ActivityDetailsPanel.vue' import ActivityDetailsPanel from '@/components/ActivityDetailsPanel.vue'
@@ -21,12 +20,11 @@ import { useActivityPage } from '@/composables/useActivityPage'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import type { ActivityFilters } from '@/lib/activity' import type { ActivityFilters } from '@/lib/activity'
import type { StripKey } from '@/lib/activitySummary' import type { StripKey } from '@/lib/activitySummary'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const router = useRouter() const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const activity = useActivityPage() const activity = useActivityPage()
const { const {
filters, filters,
+2 -3
View File
@@ -2,7 +2,6 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation' import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ApiKeyCardList from '@/components/ApiKeyCardList.vue' import ApiKeyCardList from '@/components/ApiKeyCardList.vue'
import ApiKeyReveal from '@/components/ApiKeyReveal.vue' import ApiKeyReveal from '@/components/ApiKeyReveal.vue'
@@ -16,8 +15,8 @@ import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { displayPrefix } from '@/lib/apiKeys' import { displayPrefix } from '@/lib/apiKeys'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const AUTH_HANDLED_STATUSES = [401, 403] const AUTH_HANDLED_STATUSES = [401, 403]
const NOT_FOUND_STATUS = 404 const NOT_FOUND_STATUS = 404
const REVOKE_FAILED_COPY = "Couldn't revoke the key. Try again in a moment." const REVOKE_FAILED_COPY = "Couldn't revoke the key. Try again in a moment."
@@ -30,7 +29,7 @@ const overlay = useLocalizedOverlay()
const session = useSessionStore() const session = useSessionStore()
const permissions = usePermissionsStore() const permissions = usePermissionsStore()
const { listApiKeys, revokeApiKey } = useApiKeys() const { listApiKeys, revokeApiKey } = useApiKeys()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const loadState = ref<LoadState>('loading') const loadState = ref<LoadState>('loading')
const keys = ref<ApiKey[]>([]) const keys = ref<ApiKey[]>([])
+2 -3
View File
@@ -3,7 +3,6 @@ import { t, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation' import { useLocalizedOverlay } from '@/i18n/presentation'
import AuthorAvatar from '@/components/AuthorAvatar.vue' import AuthorAvatar from '@/components/AuthorAvatar.vue'
import AuthorFormPanel from '@/components/AuthorFormPanel.vue' import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
@@ -31,8 +30,8 @@ import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu' import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px' const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
const INITIAL_SKELETON_ROWS = 6 const INITIAL_SKELETON_ROWS = 6
const NOBODY = 'none' const NOBODY = 'none'
@@ -49,7 +48,7 @@ const permissions = usePermissionsStore()
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts() const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView() const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const { listAuthors, listAuthorPage, listAllAuthors, deleteAuthor } = useAuthors() const { listAuthors, listAuthorPage, listAllAuthors, deleteAuthor } = useAuthors()
const removal = useDirectoryDelete<Author>() const removal = useDirectoryDelete<Author>()
+2 -3
View File
@@ -2,7 +2,6 @@
import { formatNumber, t } from '@/i18n' import { formatNumber, t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation' import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
@@ -27,8 +26,8 @@ import { formatVariantSize } from '@/lib/variants/variantFormat'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useVariantCacheStore } from '@/stores/variantCache' import { useVariantCacheStore } from '@/stores/variantCache'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 40rem)'
const SKELETON_DELAY_MS = 150 const SKELETON_DELAY_MS = 150
const NOT_FOUND = 404 const NOT_FOUND = 404
const CONFIRM_WORD = 'purge' const CONFIRM_WORD = 'purge'
@@ -39,7 +38,7 @@ const store = useVariantCacheStore()
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
const toast = useLocalizedToast() const toast = useLocalizedToast()
const { announceFailure, announceSuccess } = useVariantToasts() const { announceFailure, announceSuccess } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop('40rem')
const showSkeleton = ref(false) const showSkeleton = ref(false)
const purgeUnusedButton = ref<{ $el: HTMLElement } | null>(null) const purgeUnusedButton = ref<{ $el: HTMLElement } | null>(null)
+3 -3
View File
@@ -3,7 +3,7 @@ import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useRoute, type LocationQuery } from 'vue-router' import { useRoute, type LocationQuery } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core' import { useEventListener, useOnline } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation' import { useLocalizedToast } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue' import AppPaginator from '@/components/AppPaginator.vue'
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue' import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
@@ -51,8 +51,8 @@ import { pageCountFor } from '@/lib/pagination'
import { purgedToast } from '@/lib/mediaPurge' import { purgedToast } from '@/lib/mediaPurge'
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows' import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const MEDIA_PATH = '/media' const MEDIA_PATH = '/media'
const TOAST_DURATION_MS = 5000 const TOAST_DURATION_MS = 5000
const PERSISTENT_TOAST = 0 const PERSISTENT_TOAST = 0
@@ -84,7 +84,7 @@ const permissions = usePermissionsStore()
const media = useMedia() const media = useMedia()
const selection = useMediaSelection(media.items) const selection = useMediaSelection(media.items)
const upload = useMediaUpload({ onAdded: refreshInBackground }) const upload = useMediaUpload({ onAdded: refreshInBackground })
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const online = useOnline() const online = useOnline()
function differsOnlyByPage(query: MediaControls, current: MediaControls) { function differsOnlyByPage(query: MediaControls, current: MediaControls) {
+5 -12
View File
@@ -10,7 +10,8 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
import ProfileCard from '@/components/ProfileCard.vue' import ProfileCard from '@/components/ProfileCard.vue'
import YourRoleCard from '@/components/YourRoleCard.vue' import YourRoleCard from '@/components/YourRoleCard.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi' import { ApiError, authRedirectPending } from '@/composables/useApi'
import { useProfile } from '@/composables/useProfile'
import { useRetryAfter } from '@/composables/useRetryAfter' import { useRetryAfter } from '@/composables/useRetryAfter'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useSessionStore, type Session } from '@/stores/session' import { useSessionStore, type Session } from '@/stores/session'
@@ -36,7 +37,7 @@ const session = useSessionStore()
const permissions = usePermissionsStore() const permissions = usePermissionsStore()
const toast = useLocalizedToast() const toast = useLocalizedToast()
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
const { request } = useApi() const { updateDisplayName, sendPasswordReset } = useProfile()
const retryAfter = useRetryAfter() const retryAfter = useRetryAfter()
const account = ref<Session | null>(session.session) const account = ref<Session | null>(session.session)
@@ -189,11 +190,7 @@ async function onSave() {
saveFailed.value = false saveFailed.value = false
serverLimitError.value = false serverLimitError.value = false
try { try {
const updated = await request<{ display_name?: string | null }>('/v1/user/me', { const updated = await updateDisplayName(draft.value)
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: draft.value }),
})
const displayName = updated.display_name ?? '' const displayName = updated.display_name ?? ''
session.setDisplayName(displayName) session.setDisplayName(displayName)
field.value = displayName field.value = displayName
@@ -223,11 +220,7 @@ async function onSendReset() {
} }
resetState.value = 'sending' resetState.value = 'sending'
try { try {
await request('/v1/auth/password-reset', { await sendPasswordReset(email.value)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
resetState.value = 'sent' resetState.value = 'sent'
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.status === 429) { if (error instanceof ApiError && error.status === 429) {
+2 -3
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
import PermissionGrid from '@/components/PermissionGrid.vue' import PermissionGrid from '@/components/PermissionGrid.vue'
import PermissionGroup from '@/components/PermissionGroup.vue' import PermissionGroup from '@/components/PermissionGroup.vue'
@@ -14,12 +13,12 @@ import { useLocalizedOverlay } from '@/i18n/presentation'
import { useRoleEditor } from '@/composables/useRoleEditor' import { useRoleEditor } from '@/composables/useRoleEditor'
import type { CallerScope } from '@/lib/grantLocks' import type { CallerScope } from '@/lib/grantLocks'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 64rem)'
const CONTENT_GROUP = 'content' const CONTENT_GROUP = 'content'
const permissions = usePermissionsStore() const permissions = usePermissionsStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop('64rem')
const editor = useRoleEditor() const editor = useRoleEditor()
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
+2 -4
View File
@@ -2,7 +2,6 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation' import { useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
import DeleteRoleDialog from '@/components/DeleteRoleDialog.vue' import DeleteRoleDialog from '@/components/DeleteRoleDialog.vue'
@@ -14,14 +13,13 @@ import { useRoles } from '@/composables/useRoles'
import { canCover } from '@/lib/grantLocks' import { canCover } from '@/lib/grantLocks'
import type { RoleItem } from '@/lib/roles' import type { RoleItem } from '@/lib/roles'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const router = useRouter() const router = useRouter()
const toast = useLocalizedToast() const toast = useLocalizedToast()
const permissions = usePermissionsStore() const permissions = usePermissionsStore()
const { listRoles } = useRoles() const { listRoles } = useRoles()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const skeleton = useDelayedSkeleton() const skeleton = useDelayedSkeleton()
const loadState = ref<LoadState>('loading') const loadState = ref<LoadState>('loading')
+2 -3
View File
@@ -4,7 +4,6 @@ import { t } from '@/i18n'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useMediaQuery } from '@vueuse/core'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue' import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
import DirectoryLoadError from '@/components/DirectoryLoadError.vue' import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
@@ -13,13 +12,13 @@ import NoAccessState from '@/components/NoAccessState.vue'
import SchemaTable from '@/components/SchemaTable.vue' import SchemaTable from '@/components/SchemaTable.vue'
import { useSchemaGate } from '@/composables/useSchemaGate' import { useSchemaGate } from '@/composables/useSchemaGate'
import { builtinRow, customRow, schemaSummary } from '@/lib/schemaRows' import { builtinRow, customRow, schemaSummary } from '@/lib/schemaRows'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const NEW_TYPE_PATH = '/schema/new' const NEW_TYPE_PATH = '/schema/new'
const router = useRouter() const router = useRouter()
const permissions = usePermissionsStore() const permissions = usePermissionsStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const { const {
sessionReady, sessionReady,
+2 -3
View File
@@ -3,7 +3,6 @@ import { t, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation' import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
@@ -29,8 +28,8 @@ import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu' import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px' const LIST_COLUMNS = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
const INITIAL_SKELETON_ROWS = 3 const INITIAL_SKELETON_ROWS = 3
@@ -42,7 +41,7 @@ const permissions = usePermissionsStore()
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts() const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView() const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const { listAllSites, listSitePage, setDefaultSite, deleteSite } = useSites() const { listAllSites, listSitePage, setDefaultSite, deleteSite } = useSites()
const removal = useDirectoryDelete<Site>() const removal = useDirectoryDelete<Site>()
+2 -3
View File
@@ -2,7 +2,6 @@
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import ChangeRoleDialog from '@/components/ChangeRoleDialog.vue' import ChangeRoleDialog from '@/components/ChangeRoleDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
import InviteUserModal from '@/components/InviteUserModal.vue' import InviteUserModal from '@/components/InviteUserModal.vue'
@@ -15,16 +14,16 @@ import type { AcceptedUser, UserListItem } from '@/lib/users'
import type { UserMenuContext } from '@/lib/userMenu' import type { UserMenuContext } from '@/lib/userMenu'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const SKELETON_DELAY_MS = 150 const SKELETON_DELAY_MS = 150
const DESKTOP_QUERY = '(min-width: 48rem)'
type LoadState = 'loading' | 'ready' | 'error' type LoadState = 'loading' | 'ready' | 'error'
const session = useSessionStore() const session = useSessionStore()
const permissions = usePermissionsStore() const permissions = usePermissionsStore()
const { listUsers } = useUsers() const { listUsers } = useUsers()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop()
const loadState = ref<LoadState>('loading') const loadState = ref<LoadState>('loading')
const items = ref<UserListItem[]>([]) const items = ref<UserListItem[]>([])
+2 -3
View File
@@ -2,7 +2,6 @@
import { formatNumber, t } from '@/i18n' import { formatNumber, t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation' import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
@@ -30,8 +29,8 @@ import { formatVariantSize } from '@/lib/variants/variantFormat'
import { useImageVariantsStore } from '@/stores/imageVariants' import { useImageVariantsStore } from '@/stores/imageVariants'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions' import { usePermissionsStore } from '@/stores/permissions'
import { useIsDesktop } from '@/composables/useIsDesktop'
const DESKTOP_QUERY = '(min-width: 40rem)'
const SKELETON_DELAY_MS = 150 const SKELETON_DELAY_MS = 150
const NOT_FOUND = 404 const NOT_FOUND = 404
@@ -46,7 +45,7 @@ const permissions = usePermissionsStore()
const store = useImageVariantsStore() const store = useImageVariantsStore()
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
const { announceFailure, announceSuccess } = useVariantToasts() const { announceFailure, announceSuccess } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useIsDesktop('40rem')
const showSkeleton = ref(false) const showSkeleton = ref(false)
const formTarget = ref<FormTarget | null>(null) const formTarget = ref<FormTarget | null>(null)
@@ -85,7 +85,7 @@ describe('Single relation, empty, focused search', () => {
await typeSearch('venue', 'rue ') await typeSearch('venue', 'rue ')
await settleSearch(vi) await settleSearch(vi)
expect(searches()).toEqual(['/v1/content-type/venues/entry?q=rue&page=1&size=10']) expect(searches()).toEqual(['/v1/content-type/venues/entry?q=rue&page=1&size=25'])
expect(optionLabels()).toEqual([ expect(optionLabels()).toEqual([
'Rue Verte hall Lyon · 120 seats', 'Rue Verte hall Lyon · 120 seats',
'Rue Verte yard Lyon · outdoor', 'Rue Verte yard Lyon · outdoor',
@@ -103,7 +103,7 @@ describe('Single relation, empty, focused search', () => {
await openList('venue', vi) await openList('venue', vi)
expect(searches()).toEqual(['/v1/content-type/venues/entry?page=1&size=10']) expect(searches()).toEqual(['/v1/content-type/venues/entry?page=1&size=25'])
expect(optionLabels()).toHaveLength(4) expect(optionLabels()).toHaveLength(4)
}) })
@@ -82,7 +82,7 @@ describe('No match', () => {
await flushPromises() await flushPromises()
await settleSearch(vi) await settleSearch(vi)
expect(relationInput('venue').value).toBe('') expect(relationInput('venue').value).toBe('')
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=1&size=10') expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=1&size=25')
}) })
it('says the type has no entries at all', async () => { it('says the type has no entries at all', async () => {
@@ -123,7 +123,7 @@ describe('Search failures', () => {
await flushPromises() await flushPromises()
expect(calls.slice(before)).toEqual([ expect(calls.slice(before)).toEqual([
'/v1/content-type/venues/entry?q=rue&page=1&size=10', '/v1/content-type/venues/entry?q=rue&page=1&size=25',
]) ])
expect(optionLabels()).toHaveLength(3) expect(optionLabels()).toHaveLength(3)
}, },
@@ -175,7 +175,7 @@ describe('Load more', () => {
button('Load more')!.click() button('Load more')!.click()
await flushPromises() await flushPromises()
expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=2&size=10') expect(searches().at(-1)).toBe('/v1/content-type/venues/entry?page=2&size=25')
expect(optionLabels()).toHaveLength(12) expect(optionLabels()).toHaveLength(12)
expect(button('Load more')).toBeUndefined() expect(button('Load more')).toBeUndefined()
}) })
@@ -652,8 +652,7 @@ describe('Errors', () => {
'The expiry must be a date in the future.', 'The expiry must be a date in the future.',
'You must be logged in to access this resource.', 'You must be logged in to access this resource.',
'This action needs a real session.', 'This action needs a real session.',
'Only owners and admins can do this.', 'missing_permission',
'Only the owner can do this.',
'You must verify your account to access this resource.', 'You must verify your account to access this resource.',
'Article not found', 'Article not found',
'Image not found', 'Image not found',
@@ -697,8 +696,7 @@ describe('Errors table rows of the custom content types round', () => {
const order = [ const order = [
'This action needs a real session.', 'This action needs a real session.',
'Only owners and admins can do this.', 'missing_permission',
'Only the owner can do this.',
'You must verify your account to access this resource.', 'You must verify your account to access this resource.',
].map((message) => text.indexOf(message)) ].map((message) => text.indexOf(message))
expect(order.every((position) => position > -1)).toBe(true) expect(order.every((position) => position > -1)).toBe(true)
@@ -909,7 +907,7 @@ describe('Expanded cards of the custom content types round', () => {
[ [
'POST /v1/content-type/', 'POST /v1/content-type/',
['key', 'name', 'icon', 'fields'], ['key', 'name', 'icon', 'fields'],
['400', '401', 'Only owners and admins can do this.', 'content_type_key_taken'], ['400', '401', '403', 'content_type_key_taken'],
], ],
[ [
'PUT /v1/content-type/{key}', 'PUT /v1/content-type/{key}',
@@ -1134,7 +1132,7 @@ describe('Authentication copy of the custom content types round', () => {
"Requests act as the user who created the key, with that user's role (owner or admin).", "Requests act as the user who created the key, with that user's role (owner or admin).",
) )
expect(auth).toContain( expect(auth).toContain(
'Owner and admin routes answer 403 to a key whose creator is neither.', 'Routes that need a permission answer 403 "missing_permission" to a key when the role of its creator does not hold it.',
) )
}) })
}) })
@@ -187,7 +187,7 @@ describe('Search and sharing group', () => {
const wrapper = await mountIntegration() const wrapper = await mountIntegration()
const forbidden = ERROR_ROWS.find((row) => row.status === 403)! const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
expect(forbidden.messages).toHaveLength(4) expect(forbidden.messages).toHaveLength(3)
expect(forbidden.note).toBe(NOTE) expect(forbidden.note).toBe(NOTE)
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text()) const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
expect(notes.filter((item) => item === NOTE)).toHaveLength(2) expect(notes.filter((item) => item === NOTE)).toHaveLength(2)