Skip to content

Commit

Permalink
feat: Site status message (#7659)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
holloway and rjsparks authored Aug 7, 2024
1 parent 3097074 commit e5e6c9b
Show file tree
Hide file tree
Showing 26 changed files with 574 additions and 8 deletions.
8 changes: 5 additions & 3 deletions client/Embedded.vue
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down
80 changes: 80 additions & 0 deletions client/components/Status.vue
Original file line number Diff line number Diff line change
@@ -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>
2 changes: 2 additions & 0 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -20,5 +21,6 @@
</div>
</div>
<script type="module" src="./main.js"></script>
<script type="module" src="./embedded.js"></script>
</body>
</html>
20 changes: 20 additions & 0 deletions client/shared/json-wrapper.js
Original file line number Diff line number Diff line change
@@ -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 changes: 42 additions & 0 deletions client/shared/local-storage-wrapper.js
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions client/shared/status-common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Used in Playwright Status and components

export const STATUS_STORAGE_KEY = "status-dismissed"

export const generateStatusTestId = (id) => `status-${id}`
6 changes: 3 additions & 3 deletions ietf/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@
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:
Expand Down
1 change: 1 addition & 0 deletions ietf/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
'ietf.secr.meetings',
'ietf.secr.proceedings',
'ietf.ipr',
'ietf.status',
)

class CustomApiTests(TestCase):
Expand Down
1 change: 1 addition & 0 deletions ietf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ def skip_unreadable_post(record):
'ietf.release',
'ietf.review',
'ietf.stats',
'ietf.status',
'ietf.submit',
'ietf.sync',
'ietf.utils',
Expand Down
7 changes: 7 additions & 0 deletions ietf/static/css/ietf.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Empty file added ietf/status/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions ietf/status/admin.py
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions ietf/status/apps.py
Original file line number Diff line number Diff line change
@@ -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 changes: 75 additions & 0 deletions ietf/status/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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",
},
),
]
Empty file.
24 changes: 24 additions & 0 deletions ietf/status/models.py
Original file line number Diff line number Diff line change
@@ -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"
Loading

0 comments on commit e5e6c9b

Please sign in to comment.