feat: Site status message (#7659)
* Status WIP * feat: Status * fix: Status tests * feat: status redirect * chore: Status tests * chore: Status tests * feat: Status tests * chore: Status playwright tests * fix: PR feedback, mostly Vue and copyright dates * fix: Status model migration tidy up * chore: Status - one migration * feat: status on doc/html pages * chore: Resetting Status migration * chore: removing unused FieldError * fix: Update Status test to remove 'by' * chore: fixing API test to exclude 'status' * chore: fixing status_page test * feat: Site Status PR feedback. URL coverage debugging * Adding ietf.status to Tastypie omitted apps * feat: Site Status PR feedback * chore: correct copyright year on newly created files * chore: repair merge damage * chore: repair more merge damage * fix: reconcile the api init refactor with ignoring apps --------- Co-authored-by: Matthew Holloway <Matthew Holloway> Co-authored-by: Robert Sparks <rjsparks@nostrum.com>
This commit is contained in:
parent
30970749e3
commit
e5e6c9bc89
|
@ -1,12 +1,13 @@
|
|||
<template lang="pug">
|
||||
n-theme
|
||||
n-message-provider
|
||||
component(:is='currentComponent', :component-id='props.componentId')
|
||||
n-notification-provider
|
||||
n-message-provider
|
||||
component(:is='currentComponent', :component-id='props.componentId')
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineAsyncComponent, markRaw, onMounted, ref } from 'vue'
|
||||
import { NMessageProvider } from 'naive-ui'
|
||||
import { NMessageProvider, NNotificationProvider } from 'naive-ui'
|
||||
|
||||
import NTheme from './components/n-theme.vue'
|
||||
|
||||
|
@ -15,6 +16,7 @@ import NTheme from './components/n-theme.vue'
|
|||
const availableComponents = {
|
||||
ChatLog: defineAsyncComponent(() => import('./components/ChatLog.vue')),
|
||||
Polls: defineAsyncComponent(() => import('./components/Polls.vue')),
|
||||
Status: defineAsyncComponent(() => import('./components/Status.vue'))
|
||||
}
|
||||
|
||||
// PROPS
|
||||
|
|
80
client/components/Status.vue
Normal file
80
client/components/Status.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<script setup>
|
||||
import { h, onMounted } from 'vue'
|
||||
import { useNotification } from 'naive-ui'
|
||||
import { localStorageWrapper } from '../shared/local-storage-wrapper'
|
||||
import { JSONWrapper } from '../shared/json-wrapper'
|
||||
import { STATUS_STORAGE_KEY, generateStatusTestId } from '../shared/status-common'
|
||||
|
||||
const getDismissedStatuses = () => {
|
||||
const jsonString = localStorageWrapper.getItem(STATUS_STORAGE_KEY)
|
||||
const jsonValue = JSONWrapper.parse(jsonString, [])
|
||||
if(Array.isArray(jsonValue)) {
|
||||
return jsonValue
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const dismissStatus = (id) => {
|
||||
const dissmissed = [id, ...getDismissedStatuses()]
|
||||
localStorageWrapper.setItem(STATUS_STORAGE_KEY, JSONWrapper.stringify(dissmissed))
|
||||
return true
|
||||
}
|
||||
|
||||
let notificationInstances = {} // keyed by status.id
|
||||
let notification
|
||||
|
||||
const pollStatusAPI = () => {
|
||||
fetch('/status/latest.json')
|
||||
.then(resp => resp.json())
|
||||
.then(status => {
|
||||
if(status === null || status.hasMessage === false) {
|
||||
console.debug("No status message")
|
||||
return
|
||||
}
|
||||
const dismissedStatuses = getDismissedStatuses()
|
||||
if(dismissedStatuses.includes(status.id)) {
|
||||
console.debug(`Not showing site status ${status.id} because it was already dismissed. Dismissed Ids:`, dismissedStatuses)
|
||||
return
|
||||
}
|
||||
|
||||
const isSameStatusPage = Boolean(document.querySelector(`[data-status-id="${status.id}"]`))
|
||||
|
||||
if(isSameStatusPage) {
|
||||
console.debug(`Not showing site status ${status.id} because we're on the target page`)
|
||||
return
|
||||
}
|
||||
|
||||
if(notificationInstances[status.id]) {
|
||||
console.debug(`Not showing site status ${status.id} because it's already been displayed`)
|
||||
return
|
||||
}
|
||||
|
||||
notificationInstances[status.id] = notification.create({
|
||||
title: status.title,
|
||||
content: status.body,
|
||||
meta: `${status.date}`,
|
||||
action: () =>
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
'data-testid': generateStatusTestId(status.id),
|
||||
href: status.url,
|
||||
'aria-label': `Read more about ${status.title}`
|
||||
},
|
||||
"Read more"
|
||||
),
|
||||
onClose: () => {
|
||||
return dismissStatus(status.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
notification = useNotification()
|
||||
pollStatusAPI(notification)
|
||||
})
|
||||
</script>
|
|
@ -12,6 +12,7 @@
|
|||
<link href="https://static.ietf.org/fonts/noto-sans-mono/import.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="vue-embed" data-component="Status"></div>
|
||||
<div class="pt-3 container-fluid">
|
||||
<div class="row">
|
||||
<div class="col mx-lg-3" id="content">
|
||||
|
@ -20,5 +21,6 @@
|
|||
</div>
|
||||
</div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
<script type="module" src="./embedded.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
20
client/shared/json-wrapper.js
Normal file
20
client/shared/json-wrapper.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
export const JSONWrapper = {
|
||||
parse(jsonString, defaultValue) {
|
||||
if(typeof jsonString !== "string") {
|
||||
return defaultValue
|
||||
}
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return defaultValue
|
||||
},
|
||||
stringify(data) {
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
}
|
42
client/shared/local-storage-wrapper.js
Normal file
42
client/shared/local-storage-wrapper.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
|
||||
/*
|
||||
* DEVELOPER NOTE
|
||||
*
|
||||
* Some browsers can block storage (localStorage, sessionStorage)
|
||||
* access for privacy reasons, and all browsers can have storage
|
||||
* that's full, and then they throw exceptions.
|
||||
*
|
||||
* See https://michalzalecki.com/why-using-localStorage-directly-is-a-bad-idea/
|
||||
*
|
||||
* Exceptions can even be thrown when testing if localStorage
|
||||
* even exists. This can throw:
|
||||
*
|
||||
* if (window.localStorage)
|
||||
*
|
||||
* Also localStorage/sessionStorage can be enabled after DOMContentLoaded
|
||||
* so we handle it gracefully.
|
||||
*
|
||||
* 1) we need to wrap all usage in try/catch
|
||||
* 2) we need to defer actual usage of these until
|
||||
* necessary,
|
||||
*
|
||||
*/
|
||||
|
||||
export const localStorageWrapper = {
|
||||
getItem: (key) => {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
try {
|
||||
return localStorage.setItem(key, value)
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return;
|
||||
},
|
||||
}
|
5
client/shared/status-common.js
Normal file
5
client/shared/status-common.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
// Used in Playwright Status and components
|
||||
|
||||
export const STATUS_STORAGE_KEY = "status-dismissed"
|
||||
|
||||
export const generateStatusTestId = (id) => `status-${id}`
|
|
@ -21,14 +21,14 @@ from tastypie.bundle import Bundle
|
|||
from tastypie.exceptions import ApiFieldError
|
||||
from tastypie.fields import ApiField
|
||||
|
||||
|
||||
_api_list = []
|
||||
|
||||
OMITTED_APPS_APIS = ["ietf.status"]
|
||||
|
||||
def populate_api_list():
|
||||
_module_dict = globals()
|
||||
for app_config in django_apps.get_app_configs():
|
||||
_module_dict = globals()
|
||||
if '.' in app_config.name:
|
||||
if '.' in app_config.name and app_config.name not in OMITTED_APPS_APIS:
|
||||
_root, _name = app_config.name.split('.', 1)
|
||||
if _root == 'ietf':
|
||||
if not '.' in _name:
|
||||
|
|
|
@ -48,6 +48,7 @@ OMITTED_APPS = (
|
|||
'ietf.secr.meetings',
|
||||
'ietf.secr.proceedings',
|
||||
'ietf.ipr',
|
||||
'ietf.status',
|
||||
)
|
||||
|
||||
class CustomApiTests(TestCase):
|
||||
|
|
|
@ -479,6 +479,7 @@ INSTALLED_APPS = [
|
|||
'ietf.release',
|
||||
'ietf.review',
|
||||
'ietf.stats',
|
||||
'ietf.status',
|
||||
'ietf.submit',
|
||||
'ietf.sync',
|
||||
'ietf.utils',
|
||||
|
|
|
@ -1189,6 +1189,13 @@ blockquote {
|
|||
border-left: solid 1px var(--bs-body-color);
|
||||
}
|
||||
|
||||
iframe.status {
|
||||
background-color:transparent;
|
||||
border:none;
|
||||
width:100%;
|
||||
height:3.5em;
|
||||
}
|
||||
|
||||
.overflow-shadows {
|
||||
transition: box-shadow 0.5s;
|
||||
}
|
||||
|
|
0
ietf/status/__init__.py
Normal file
0
ietf/status/__init__.py
Normal file
19
ietf/status/admin.py
Normal file
19
ietf/status/admin.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Copyright The IETF Trust 2024, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from datetime import datetime
|
||||
from django.contrib import admin
|
||||
from django.template.defaultfilters import slugify
|
||||
from .models import Status
|
||||
|
||||
class StatusAdmin(admin.ModelAdmin):
|
||||
list_display = ['title', 'body', 'active', 'date', 'by', 'page']
|
||||
raw_id_fields = ['by']
|
||||
|
||||
def get_changeform_initial_data(self, request):
|
||||
date = datetime.now()
|
||||
return {
|
||||
"slug": slugify(f"{date.year}-{date.month}-{date.day}-"),
|
||||
}
|
||||
|
||||
admin.site.register(Status, StatusAdmin)
|
9
ietf/status/apps.py
Normal file
9
ietf/status/apps.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Copyright The IETF Trust 2024, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class StatusConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "ietf.status"
|
75
ietf/status/migrations/0001_initial.py
Normal file
75
ietf/status/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
# Generated by Django 4.2.13 on 2024-07-21 22:47
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("person", "0002_alter_historicalperson_ascii_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Status",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date", models.DateTimeField(default=django.utils.timezone.now)),
|
||||
("slug", models.SlugField(unique=True)),
|
||||
(
|
||||
"title",
|
||||
models.CharField(
|
||||
help_text="Your site status notification title.",
|
||||
max_length=255,
|
||||
verbose_name="Status title",
|
||||
),
|
||||
),
|
||||
(
|
||||
"body",
|
||||
models.CharField(
|
||||
help_text="Your site status notification body.",
|
||||
max_length=255,
|
||||
verbose_name="Status body",
|
||||
),
|
||||
),
|
||||
(
|
||||
"active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Only active messages will be shown.",
|
||||
verbose_name="Active?",
|
||||
),
|
||||
),
|
||||
(
|
||||
"page",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="More detail shown after people click 'Read more'. If empty no 'read more' will be shown",
|
||||
null=True,
|
||||
verbose_name="More detail (markdown)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="person.person"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "statuses",
|
||||
},
|
||||
),
|
||||
]
|
0
ietf/status/migrations/__init__.py
Normal file
0
ietf/status/migrations/__init__.py
Normal file
24
ietf/status/models.py
Normal file
24
ietf/status/models.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Copyright The IETF Trust 2024, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db import models
|
||||
from django.db.models import ForeignKey
|
||||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
class Status(models.Model):
|
||||
name = 'Status'
|
||||
|
||||
date = models.DateTimeField(default=timezone.now)
|
||||
slug = models.SlugField(blank=False, null=False, unique=True)
|
||||
title = models.CharField(max_length=255, verbose_name="Status title", help_text="Your site status notification title.")
|
||||
body = models.CharField(max_length=255, verbose_name="Status body", help_text="Your site status notification body.", unique=False)
|
||||
active = models.BooleanField(default=True, verbose_name="Active?", help_text="Only active messages will be shown.")
|
||||
by = ForeignKey('person.Person', on_delete=models.CASCADE)
|
||||
page = models.TextField(blank=True, null=True, verbose_name="More detail (markdown)", help_text="More detail shown after people click 'Read more'. If empty no 'read more' will be shown")
|
||||
|
||||
def __str__(self):
|
||||
return "{} {} {} {}".format(self.date, self.active, self.by, self.title)
|
||||
class Meta:
|
||||
verbose_name_plural = "statuses"
|
120
ietf/status/tests.py
Normal file
120
ietf/status/tests.py
Normal file
|
@ -0,0 +1,120 @@
|
|||
# Copyright The IETF Trust 2024, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from django.urls import reverse as urlreverse
|
||||
from ietf.utils.test_utils import TestCase
|
||||
from ietf.person.models import Person
|
||||
from ietf.status.models import Status
|
||||
|
||||
class StatusTests(TestCase):
|
||||
def test_status_latest_html(self):
|
||||
status = Status.objects.create(
|
||||
title = "my title 1",
|
||||
body = "my body 1",
|
||||
active = True,
|
||||
by = Person.objects.get(user__username='ad'),
|
||||
slug = "2024-1-1-my-title-1"
|
||||
)
|
||||
status.save()
|
||||
|
||||
url = urlreverse('ietf.status.views.status_latest_html')
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, 'my title 1')
|
||||
self.assertContains(r, 'my body 1')
|
||||
|
||||
status.delete()
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertNotContains(r, 'my title 1')
|
||||
self.assertNotContains(r, 'my body 1')
|
||||
|
||||
def test_status_latest_json(self):
|
||||
url = urlreverse('ietf.status.views.status_latest_json')
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = r.json()
|
||||
self.assertFalse(data["hasMessage"])
|
||||
|
||||
status = Status.objects.create(
|
||||
title = "my title 1",
|
||||
body = "my body 1",
|
||||
active = True,
|
||||
by = Person.objects.get(user__username='ad'),
|
||||
slug = "2024-1-1-my-title-1"
|
||||
)
|
||||
status.save()
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = r.json()
|
||||
self.assertTrue(data["hasMessage"])
|
||||
self.assertEqual(data["title"], "my title 1")
|
||||
self.assertEqual(data["body"], "my body 1")
|
||||
self.assertEqual(data["slug"], '2024-1-1-my-title-1')
|
||||
self.assertEqual(data["url"], '/status/2024-1-1-my-title-1')
|
||||
|
||||
status.delete()
|
||||
|
||||
def test_status_latest_redirect(self):
|
||||
url = urlreverse('ietf.status.views.status_latest_redirect')
|
||||
r = self.client.get(url)
|
||||
# without a Status it should return Not Found
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
status = Status.objects.create(
|
||||
title = "my title 1",
|
||||
body = "my body 1",
|
||||
active = True,
|
||||
by = Person.objects.get(user__username='ad'),
|
||||
slug = "2024-1-1-my-title-1"
|
||||
)
|
||||
status.save()
|
||||
|
||||
r = self.client.get(url)
|
||||
# with a Status it should redirect
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertEqual(r.headers["Location"], "/status/2024-1-1-my-title-1")
|
||||
|
||||
status.delete()
|
||||
|
||||
def test_status_page(self):
|
||||
slug = "2024-1-1-my-unique-slug"
|
||||
r = self.client.get(f'/status/{slug}/')
|
||||
# without a Status it should return Not Found
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
# status without `page` markdown should still 200
|
||||
status = Status.objects.create(
|
||||
title = "my title 1",
|
||||
body = "my body 1",
|
||||
active = True,
|
||||
by = Person.objects.get(user__username='ad'),
|
||||
slug = slug
|
||||
)
|
||||
status.save()
|
||||
|
||||
r = self.client.get(f'/status/{slug}/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
status.delete()
|
||||
|
||||
test_string = 'a string that'
|
||||
status = Status.objects.create(
|
||||
title = "my title 1",
|
||||
body = "my body 1",
|
||||
active = True,
|
||||
by = Person.objects.get(user__username='ad'),
|
||||
slug = slug,
|
||||
page = f"# {test_string}"
|
||||
)
|
||||
status.save()
|
||||
|
||||
r = self.client.get(f'/status/{slug}/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, test_string)
|
||||
|
||||
status.delete()
|
12
ietf/status/urls.py
Normal file
12
ietf/status/urls.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
# Copyright The IETF Trust 2024, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from ietf.status import views
|
||||
from ietf.utils.urls import url
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^$", views.status_latest_redirect),
|
||||
url(r"^latest$", views.status_latest_html),
|
||||
url(r"^latest.json$", views.status_latest_json),
|
||||
url(r"(?P<slug>.*)", views.status_page)
|
||||
]
|
46
ietf/status/views.py
Normal file
46
ietf/status/views.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
# Copyright The IETF Trust 2024, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.urls import reverse as urlreverse
|
||||
from django.http import HttpResponseRedirect, HttpResponseNotFound, JsonResponse
|
||||
from ietf.utils import markdown
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from ietf.status.models import Status
|
||||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
def get_last_active_status():
|
||||
status = Status.objects.filter(active=True).order_by("-date").first()
|
||||
if status is None:
|
||||
return { "hasMessage": False }
|
||||
|
||||
context = {
|
||||
"hasMessage": True,
|
||||
"id": status.id,
|
||||
"slug": status.slug,
|
||||
"title": status.title,
|
||||
"body": status.body,
|
||||
"url": urlreverse("ietf.status.views.status_page", kwargs={ "slug": status.slug }),
|
||||
"date": status.date.isoformat()
|
||||
}
|
||||
return context
|
||||
|
||||
def status_latest_html(request):
|
||||
return render(request, "status/latest.html", context=get_last_active_status())
|
||||
|
||||
def status_page(request, slug):
|
||||
sanitised_slug = slug.rstrip("/")
|
||||
status = get_object_or_404(Status, slug=sanitised_slug)
|
||||
return render(request, "status/status.html", context={
|
||||
'status': status,
|
||||
'status_page_html': markdown.markdown(status.page or ""),
|
||||
})
|
||||
|
||||
def status_latest_json(request):
|
||||
return JsonResponse(get_last_active_status())
|
||||
|
||||
def status_latest_redirect(request):
|
||||
context = get_last_active_status()
|
||||
if context["hasMessage"] == True:
|
||||
return HttpResponseRedirect(context["url"])
|
||||
return HttpResponseNotFound()
|
|
@ -34,6 +34,7 @@
|
|||
<body {% block bodyAttrs %}{% endblock %} class="navbar-offset position-relative"
|
||||
data-group-menu-data-url="{% url 'ietf.group.views.group_menu_data' %}">
|
||||
{% analytical_body_top %}
|
||||
{% include "base/status.html" %}
|
||||
<a class="visually-hidden visually-hidden-focusable" href="#content">Skip to main content</a>
|
||||
<nav class="navbar navbar-expand-lg fixed-top {% if server_mode and server_mode != "production" %}bg-danger-subtle{% else %}bg-secondary-subtle{% endif %}">
|
||||
<div class="container-fluid">
|
||||
|
@ -85,7 +86,7 @@
|
|||
</div>
|
||||
</nav>
|
||||
{% block precontent %}{% endblock %}
|
||||
<div class="pt-3 container-fluid">
|
||||
<main class="pt-3 container-fluid" id="main">
|
||||
<div class="row">
|
||||
{% if request.COOKIES.left_menu == "on" and not hide_menu %}
|
||||
<div class="d-none d-md-block bg-light-subtle py-3 leftmenu small">
|
||||
|
@ -114,7 +115,7 @@
|
|||
{% block content_end %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% block footer %}
|
||||
<footer class="col-md-12 col-sm-12 border-top mt-5 py-5 bg-light-subtle text-center position-sticky">
|
||||
<a href="https://www.ietf.org/" class="p-3">IETF</a>
|
||||
|
|
2
ietf/templates/base/status.html
Normal file
2
ietf/templates/base/status.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
<noscript><iframe class="status" title="Site status" src="/status/latest"></iframe></noscript>
|
||||
<div class="vue-embed" data-component="Status"></div>
|
|
@ -4,6 +4,7 @@
|
|||
{% load origin %}
|
||||
{% load static %}
|
||||
{% load ietf_filters textfilters %}
|
||||
{% load django_vite %}
|
||||
{% origin %}
|
||||
<html data-bs-theme="auto" lang="en">
|
||||
<head>
|
||||
|
@ -28,6 +29,7 @@
|
|||
{% if html %}
|
||||
<link rel="stylesheet" href="{% static 'ietf/css/document_html_txt.css' %}">
|
||||
{% endif %}
|
||||
{% vite_asset 'client/embedded.js' %}
|
||||
<script src="{% static 'ietf/js/document_html.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/theme.js' %}"></script>
|
||||
{% endif %}
|
||||
|
@ -51,6 +53,7 @@
|
|||
</head>
|
||||
<body>
|
||||
{% analytical_body_top %}
|
||||
{% include "base/status.html" %}
|
||||
<div class="btn-toolbar sidebar-toolbar position-fixed top-0 end-0 m-2 m-lg-3 d-print-none">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm me-1 dropdown-toggle d-flex align-items-center"
|
||||
|
|
18
ietf/templates/status/latest.html
Normal file
18
ietf/templates/status/latest.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% load origin %}
|
||||
{% load ietf_filters static %}
|
||||
{% origin %}
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<style type="text/css">
|
||||
{# this template doesn't inherit from base.html so it has its own styles #}
|
||||
body {background:transparent;font-family:sans-serif}
|
||||
h1{font-size:18px;display:inline}
|
||||
p{font-size:14px;display:inline}
|
||||
.unimportant{opacity:0.6}
|
||||
</style>
|
||||
<!-- This page is intended to be iframed, and is only for non-JavaScript browsers. -->
|
||||
{% if title %}
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ body }} <a href="{{ url }}" target="_top" aria-label="read more about {{title}}">read more</a><br /><span class="unimportant">{{ date }}</span></p>
|
||||
{% else %}
|
||||
<p class="unimportant">No site status message.</p>
|
||||
{% endif %}
|
15
ietf/templates/status/status.html
Normal file
15
ietf/templates/status/status.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends "base.html" %}
|
||||
{% load origin %}
|
||||
{% load ietf_filters static %}
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1 data-status-id="{{ status.id }}">
|
||||
{% block title %} {{ status.title }} {% endblock %}
|
||||
{% if status.active == False %}
|
||||
<span class="badge bg-secondary">inactive</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<div>
|
||||
{{ status_page_html }}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -61,6 +61,7 @@ urlpatterns = [
|
|||
url(r'^sitemap-(?P<section>.+).xml$', sitemap_views.sitemap, {'sitemaps': sitemaps}),
|
||||
url(r'^sitemap.xml$', sitemap_views.index, { 'sitemaps': sitemaps}),
|
||||
url(r'^stats/', include('ietf.stats.urls')),
|
||||
url(r'^status/', include('ietf.status.urls')),
|
||||
url(r'^stream/', include(stream_urls)),
|
||||
url(r'^submit/', include('ietf.submit.urls')),
|
||||
url(r'^sync/', include('ietf.sync.urls')),
|
||||
|
|
61
playwright/tests/status/status.spec.js
Normal file
61
playwright/tests/status/status.spec.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
const {
|
||||
test,
|
||||
expect
|
||||
} = require('@playwright/test')
|
||||
const { STATUS_STORAGE_KEY, generateStatusTestId } = require('../../../client/shared/status-common.js')
|
||||
|
||||
test.describe('site status', () => {
|
||||
const noStatus = {
|
||||
hasMessage: false
|
||||
}
|
||||
|
||||
const status1 = {
|
||||
hasMessage: true,
|
||||
id: 1,
|
||||
slug: '2024-7-9fdfdf-sdfsdf',
|
||||
title: 'My status title',
|
||||
body: 'My status body',
|
||||
url: '/status/2024-7-9fdfdf-sdfsdf',
|
||||
date: '2024-07-09T07:05:13+00:00',
|
||||
by: 'Exile is a cool Amiga game'
|
||||
}
|
||||
|
||||
test('Renders server status as Notification', async ({ page }) => {
|
||||
await page.route('/status/latest.json', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(status1)
|
||||
})
|
||||
})
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId(generateStatusTestId(status1.id)), 'should have status').toHaveCount(1)
|
||||
})
|
||||
|
||||
test("Doesn't render dismissed server statuses", async ({ page }) => {
|
||||
await page.route('/status/latest.json', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(status1)
|
||||
})
|
||||
})
|
||||
await page.goto('/')
|
||||
await page.evaluate(({ key, value }) => localStorage.setItem(key, value), { key: STATUS_STORAGE_KEY, value: JSON.stringify([status1.id]) })
|
||||
await expect(page.getByTestId(generateStatusTestId(status1.id)), 'should have status').toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Handles no server status', async ({ page }) => {
|
||||
await page.route('/status/latest.json', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(noStatus)
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByTestId(generateStatusTestId(status1.id)), 'should have status').toHaveCount(0)
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue