Simplify release notes workflow
Some checks failed
deploy-socialize / image (push) Successful in 1m9s
deploy-socialize / deploy (push) Has been cancelled

This commit is contained in:
2026-05-08 00:37:14 -04:00
parent 2eb54b9228
commit dcfdce1ec6
47 changed files with 12370 additions and 1974 deletions

View File

@@ -164,22 +164,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/developer/release-commits/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits": {
parameters: {
query?: never;
@@ -260,7 +244,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/developer/release-updates/{id}/send-email": {
"/api/developer/release-commits/refresh": {
parameters: {
query?: never;
header?: never;
@@ -269,7 +253,7 @@ export interface paths {
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler"];
post: operations["SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler"];
delete?: never;
options?: never;
head?: never;
@@ -292,6 +276,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/developer/release-commits/{sha}/link-first-release": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits/{sha}/unlink": {
parameters: {
query?: never;
@@ -1471,15 +1471,12 @@ export interface components {
/** Format: guid */
id?: string;
title?: string;
summary?: string;
body?: string | null;
category?: string;
importance?: string;
audience?: string;
description?: string;
titleEn?: string;
descriptionEn?: string;
titleFr?: string;
descriptionFr?: string;
status?: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
@@ -1488,25 +1485,13 @@ export interface components {
publishedAt?: string | null;
/** Format: date-time */
archivedAt?: string | null;
/** Format: guid */
manualEmailSentByUserId?: string | null;
/** Format: date-time */
manualEmailSentAt?: string | null;
manualEmailAudience?: string | null;
/** Format: int32 */
manualEmailRecipientCount?: number | null;
isRead?: boolean;
};
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: {
title: string;
summary: string;
body?: string | null;
category: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
titleEn: string;
descriptionEn: string;
titleFr: string;
descriptionFr: string;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
/** Format: int32 */
@@ -1515,15 +1500,6 @@ export interface components {
importantUnreadCount?: number;
updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto: {
/** Format: int32 */
importedCount?: number;
/** Format: int32 */
updatedCount?: number;
/** Format: int32 */
skippedCount?: number;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: {
sha?: string;
shortSha?: string;
@@ -1545,52 +1521,32 @@ export interface components {
/** Format: date-time */
updatedAt?: string;
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest: {
sinceSha?: string | null;
untilSha?: string | null;
sourceBranch?: string | null;
deploymentLabel?: string | null;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto"][] | null;
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto: {
sha?: string;
shortSha?: string | null;
subject?: string;
authorName?: string | null;
authorEmail?: string | null;
/** Format: date-time */
authoredAt?: string | null;
/** Format: date-time */
committedAt?: string | null;
sourceBranch?: string | null;
deploymentLabel?: string | null;
externalUrl?: string | null;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto: {
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto: {
/** Format: int32 */
recipientCount?: number;
/** Format: date-time */
sentAt?: string;
testMode?: boolean;
};
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest: {
testMode?: boolean;
confirmResend?: boolean;
createdCount?: number;
/** Format: int32 */
updatedCount?: number;
/** Format: int32 */
skippedCount?: number;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
};
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: {
/** Format: guid */
releaseUpdateId?: string;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto: {
/** Format: int32 */
linkedCount?: number;
};
SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest: {
/** Format: guid */
releaseUpdateId?: string;
};
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: {
title: string;
summary: string;
body?: string | null;
category: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
titleEn: string;
descriptionEn: string;
titleFr: string;
descriptionFr: string;
};
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
/** Format: guid */
@@ -2871,44 +2827,6 @@ export interface operations {
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler: {
parameters: {
query?: never;
@@ -3058,20 +2976,14 @@ export interface operations {
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler: {
SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest"];
};
};
requestBody?: never;
responses: {
/** @description Success */
200: {
@@ -3079,7 +2991,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto"];
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto"];
};
};
/** @description Unauthorized */
@@ -3138,6 +3050,46 @@ export interface operations {
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsHandler: {
parameters: {
query?: never;
header?: never;
path: {
sha: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler: {
parameters: {
query?: never;

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "tailwindcss/theme.css";
@import "tailwindcss/utilities.css";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
@@ -29,59 +30,6 @@ textarea::placeholder {
opacity: 1;
}
.v-application {
background: rgb(var(--v-theme-background)) !important;
color: rgb(var(--v-theme-on-background));
}
.v-card,
.v-sheet,
.v-list,
.v-menu > .v-overlay__content,
.v-dialog > .v-overlay__content {
background-color: rgb(var(--v-theme-surface)) !important;
border: 1px solid rgb(var(--v-theme-border));
}
.v-field {
background-color: rgb(var(--v-theme-control)) !important;
color: rgb(var(--v-theme-on-surface));
}
.v-field:hover {
background-color: rgb(var(--v-theme-control-hover)) !important;
}
.v-field--focused {
background-color: rgb(var(--v-theme-control-focus)) !important;
}
.v-field__outline {
color: rgb(var(--v-theme-border-strong));
}
.v-field--focused .v-field__outline {
color: rgb(var(--v-theme-highlight));
}
.v-field__input,
.v-field-label {
color: rgb(var(--v-theme-on-surface));
}
.v-select .v-field .v-field__input > input,
.v-select .v-field .v-field__input > input::placeholder {
color: transparent !important;
caret-color: transparent;
}
.panel,
[class$='-panel'],
[class$='-card'],
div.card {
border-color: rgb(var(--v-theme-border)) !important;
}
@layer components {
.btn {
@apply min-w-24 w-full;

View File

@@ -0,0 +1,54 @@
export function formatReleaseDescription(value) {
const lines = String(value ?? '').replace(/\r\n?/g, '\n').split('\n');
const blocks = [];
let paragraphLines = [];
let listItems = [];
function flushParagraph() {
if (!paragraphLines.length) {
return;
}
blocks.push({
type: 'paragraph',
text: paragraphLines.join(' ').trim(),
});
paragraphLines = [];
}
function flushList() {
if (!listItems.length) {
return;
}
blocks.push({
type: 'list',
items: listItems,
});
listItems = [];
}
lines.forEach(line => {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
flushList();
return;
}
const bullet = trimmed.match(/^[-*]\s+(.+)$/);
if (bullet) {
flushParagraph();
listItems.push(bullet[1].trim());
return;
}
flushList();
paragraphLines.push(trimmed);
});
flushParagraph();
flushList();
return blocks;
}

View File

@@ -3,17 +3,9 @@ import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
const DEFAULT_COMMIT_FILTERS = Object.freeze({
status: '',
updateId: '',
author: '',
search: '',
inclusion: 'notIncluded',
});
export const RELEASE_UPDATE_CATEGORIES = ['Feature', 'Improvement', 'Fix', 'Breaking Change'];
export const RELEASE_UPDATE_IMPORTANCE = ['Normal', 'Important'];
export const RELEASE_UPDATE_AUDIENCES = ['Everyone', 'OrganizationOwners', 'Developers'];
export const RELEASE_COMMIT_STATUSES = ['Unreviewed', 'Linked', 'InternalOnly', 'Ignored'];
export const useReleaseCommunicationsStore = defineStore('release-communications', () => {
const client = useClient();
const updates = ref([]);
@@ -21,11 +13,11 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
const developerUpdates = ref([]);
const selectedUpdate = ref(null);
const commits = ref([]);
const selectedCommitShas = ref([]);
const commitFilters = ref({ ...DEFAULT_COMMIT_FILTERS });
const isLoading = ref(false);
const isSaving = ref(false);
const isSendingEmail = ref(false);
const isImporting = ref(false);
const isRefreshingCommits = ref(false);
const error = ref(null);
const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0);
@@ -35,40 +27,15 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
);
const filteredCommits = computed(() => {
const query = commitFilters.value.search.trim().toLowerCase();
const author = commitFilters.value.author.trim().toLowerCase();
return commits.value.filter(commit => {
if (commitFilters.value.status && commit.communicationStatus !== commitFilters.value.status) {
if (commitFilters.value.inclusion === 'included' && !commit.releaseUpdateId) {
return false;
}
if (commitFilters.value.updateId && commit.releaseUpdateId !== commitFilters.value.updateId) {
if (commitFilters.value.inclusion === 'notIncluded' && commit.releaseUpdateId) {
return false;
}
if (author) {
const authorText = `${commit.authorName ?? ''} ${commit.authorEmail ?? ''}`.toLowerCase();
if (!authorText.includes(author)) {
return false;
}
}
if (query) {
const haystack = [
commit.sha,
commit.shortSha,
commit.subject,
commit.authorName,
commit.authorEmail,
commit.deploymentLabel,
commit.sourceBranch,
].filter(Boolean).join(' ').toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
return true;
});
});
@@ -159,28 +126,19 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
return response.data;
}
async function sendUpdateEmail(id, payload) {
isSendingEmail.value = true;
try {
return (await client.post(`/api/developer/release-updates/${id}/send-email`, payload)).data;
} finally {
isSendingEmail.value = false;
}
}
async function loadCommits() {
const response = await client.get('/api/developer/release-commits');
commits.value = response.data ?? [];
}
async function importCommits(payload) {
isImporting.value = true;
async function refreshCommits() {
isRefreshingCommits.value = true;
try {
const response = await client.post('/api/developer/release-commits/import', payload);
const response = await client.post('/api/developer/release-commits/refresh');
await loadCommits();
return response.data;
} finally {
isImporting.value = false;
isRefreshingCommits.value = false;
}
}
@@ -194,6 +152,12 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
await Promise.all([loadCommits(), loadDeveloperUpdates()]);
}
async function linkFirstReleaseCommits(anchorSha, releaseUpdateId) {
const response = await client.post(`/api/developer/release-commits/${anchorSha}/link-first-release`, { releaseUpdateId });
await Promise.all([loadCommits(), loadDeveloperUpdates()]);
return response.data;
}
async function unlinkCommit(sha) {
await client.post(`/api/developer/release-commits/${sha}/unlink`);
await loadCommits();
@@ -213,12 +177,23 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
commitFilters.value = { ...DEFAULT_COMMIT_FILTERS };
}
function setCommitSelected(sha, selected) {
selectedCommitShas.value = selected
? [...new Set([...selectedCommitShas.value, sha])]
: selectedCommitShas.value.filter(selectedSha => selectedSha !== sha);
}
function clearSelectedCommits() {
selectedCommitShas.value = [];
}
return {
updates,
unreadSummary,
developerUpdates,
selectedUpdate,
commits,
selectedCommitShas,
commitFilters,
filteredCommits,
unreadCount,
@@ -226,8 +201,7 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
unreviewedCommitCount,
isLoading,
isSaving,
isSendingEmail,
isImporting,
isRefreshingCommits,
error,
loadUserUpdates,
loadUnreadSummary,
@@ -238,14 +212,16 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
saveDeveloperUpdate,
publishUpdate,
archiveUpdate,
sendUpdateEmail,
loadCommits,
importCommits,
refreshCommits,
linkCommit,
linkCommitsToUpdate,
linkFirstReleaseCommits,
unlinkCommit,
markCommitInternalOnly,
ignoreCommit,
resetCommitFilters,
setCommitSelected,
clearSelectedCommits,
};
});

View File

@@ -1,246 +0,0 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
RELEASE_UPDATE_AUDIENCES,
RELEASE_UPDATE_CATEGORIES,
RELEASE_UPDATE_IMPORTANCE,
useReleaseCommunicationsStore,
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const store = useReleaseCommunicationsStore();
const editingId = ref(null);
const form = reactive({
title: '',
summary: '',
body: '',
category: 'Feature',
importance: 'Normal',
audience: 'Everyone',
deploymentLabel: '',
buildVersion: '',
commitRange: '',
});
const emailTestMode = ref(true);
const confirmResend = ref(false);
const emailResult = ref(null);
const linkedCommits = computed(() =>
editingId.value
? store.commits.filter(commit => commit.releaseUpdateId === editingId.value)
: []
);
onMounted(async () => {
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
});
function editUpdate(update) {
editingId.value = update.id;
Object.assign(form, {
title: update.title ?? '',
summary: update.summary ?? '',
body: update.body ?? '',
category: update.category ?? 'Feature',
importance: update.importance ?? 'Normal',
audience: update.audience ?? 'Everyone',
deploymentLabel: update.deploymentLabel ?? '',
buildVersion: update.buildVersion ?? '',
commitRange: update.commitRange ?? '',
});
store.selectedUpdate = update;
}
function newUpdate() {
editingId.value = null;
Object.assign(form, {
title: '',
summary: '',
body: '',
category: 'Feature',
importance: 'Normal',
audience: 'Everyone',
deploymentLabel: '',
buildVersion: '',
commitRange: '',
});
emailResult.value = null;
}
async function save() {
await store.saveDeveloperUpdate({ ...form }, editingId.value);
editingId.value = store.selectedUpdate?.id ?? editingId.value;
}
async function sendEmail() {
if (!editingId.value || !window.confirm(t('releaseCommunications.developer.confirmEmail'))) {
return;
}
emailResult.value = await store.sendUpdateEmail(editingId.value, {
testMode: emailTestMode.value,
confirmResend: confirmResend.value,
});
await store.loadDeveloperUpdate(editingId.value);
await store.loadDeveloperUpdates();
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : t('releaseCommunications.emptyValue');
}
</script>
<template>
<section class="developer-updates-page">
<header class="page-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.developer.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.developer.title') }}</h1>
</div>
<v-btn @click="newUpdate">{{ t('releaseCommunications.developer.newUpdate') }}</v-btn>
</header>
<section class="editor-grid">
<form
class="editor-panel"
@submit.prevent="save"
>
<v-text-field v-model="form.title" :label="t('title')" density="compact" variant="outlined" />
<v-textarea v-model="form.summary" :label="t('releaseCommunications.summary')" rows="2" variant="outlined" />
<v-textarea v-model="form.body" :label="t('releaseCommunications.body')" rows="5" variant="outlined" />
<div class="form-row">
<v-select v-model="form.category" :items="RELEASE_UPDATE_CATEGORIES" :label="t('releaseCommunications.category')" density="compact" variant="outlined" />
<v-select v-model="form.importance" :items="RELEASE_UPDATE_IMPORTANCE" :label="t('releaseCommunications.importance')" density="compact" variant="outlined" />
<v-select v-model="form.audience" :items="RELEASE_UPDATE_AUDIENCES" :label="t('releaseCommunications.audience')" density="compact" variant="outlined" />
</div>
<div class="form-row">
<v-text-field v-model="form.deploymentLabel" :label="t('releaseCommunications.deploymentLabel')" density="compact" variant="outlined" />
<v-text-field v-model="form.buildVersion" :label="t('releaseCommunications.buildVersion')" density="compact" variant="outlined" />
<v-text-field v-model="form.commitRange" :label="t('releaseCommunications.commitRange')" density="compact" variant="outlined" />
</div>
<div class="actions">
<v-btn type="submit" :loading="store.isSaving">{{ t('save') }}</v-btn>
<v-btn v-if="editingId" variant="outlined" @click="store.publishUpdate(editingId)">{{ t('releaseCommunications.developer.publish') }}</v-btn>
<v-btn v-if="editingId" variant="outlined" @click="store.archiveUpdate(editingId)">{{ t('releaseCommunications.developer.archive') }}</v-btn>
</div>
<div
v-if="editingId"
class="email-panel"
>
<strong>{{ t('releaseCommunications.developer.pushEmail') }}</strong>
<v-checkbox v-model="emailTestMode" :label="t('releaseCommunications.developer.testMode')" density="compact" hide-details />
<v-checkbox v-model="confirmResend" :label="t('releaseCommunications.developer.confirmResend')" density="compact" hide-details />
<v-btn variant="outlined" :loading="store.isSendingEmail" @click="sendEmail">{{ t('releaseCommunications.developer.sendEmail') }}</v-btn>
<small v-if="emailResult">{{ t('releaseCommunications.developer.emailResult', { count: emailResult.recipientCount }) }}</small>
</div>
</form>
<aside class="updates-panel">
<button
v-for="update in store.developerUpdates"
:key="update.id"
class="update-row"
type="button"
@click="editUpdate(update)"
>
<strong>{{ update.title }}</strong>
<span>{{ update.status }} / {{ update.audience }}</span>
<small>{{ formatDate(update.publishedAt ?? update.createdAt) }}</small>
</button>
</aside>
</section>
<section
v-if="editingId"
class="linked-commits"
>
<h2>{{ t('releaseCommunications.developer.linkedCommits') }}</h2>
<div v-if="!linkedCommits.length" class="page-message">{{ t('releaseCommunications.developer.noLinkedCommits') }}</div>
<div
v-for="commit in linkedCommits"
:key="commit.sha"
class="commit-chip"
>
<code>{{ commit.shortSha }}</code>
<span>{{ commit.subject }}</span>
</div>
</section>
</section>
</template>
<style scoped>
.developer-updates-page {
display: grid;
gap: 20px;
padding: 24px;
}
.page-header,
.actions,
.form-row {
display: flex;
gap: 12px;
}
.page-header {
align-items: center;
justify-content: space-between;
}
.eyebrow {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.editor-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.7fr);
gap: 16px;
}
.editor-panel,
.updates-panel,
.linked-commits {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 16px;
}
.update-row {
display: grid;
width: 100%;
gap: 3px;
border: 0;
border-bottom: 1px solid #e2e8f0;
background: transparent;
padding: 10px 0;
text-align: left;
}
.email-panel {
display: grid;
gap: 8px;
margin-top: 16px;
border-top: 1px solid #e2e8f0;
padding-top: 16px;
}
.commit-chip {
display: flex;
gap: 10px;
padding: 8px 0;
}
@media (max-width: 900px) {
.editor-grid,
.form-row {
grid-template-columns: 1fr;
flex-direction: column;
}
}
</style>

View File

@@ -2,9 +2,10 @@
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { formatReleaseDescription } from '@/features/release-communications/formatReleaseDescription.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const { locale, t } = useI18n();
const route = useRoute();
const store = useReleaseCommunicationsStore();
@@ -12,31 +13,46 @@
onMounted(async () => {
await store.loadUserUpdates();
if (highlightedId.value) {
await store.markRead(highlightedId.value);
if (store.updates.some(update => !update.isRead)) {
await store.markAllRead();
}
});
function formatDate(value) {
return value ? new Date(value).toLocaleString() : '';
if (!value) {
return '';
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'long',
}).format(new Date(value));
}
function updateTitle(update) {
return locale.value.startsWith('fr')
? update.titleFr || update.title
: update.titleEn || update.title;
}
function updateDescription(update) {
return locale.value.startsWith('fr')
? update.descriptionFr || update.description
: update.descriptionEn || update.description;
}
function updateDescriptionBlocks(update) {
return formatReleaseDescription(updateDescription(update));
}
</script>
<template>
<section class="updates-page">
<header class="updates-header">
<header class="page-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.user.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.user.title') }}</h1>
<p>{{ t('releaseCommunications.user.description') }}</p>
</div>
<v-btn
variant="outlined"
:disabled="!store.unreadCount"
@click="store.markAllRead"
>
{{ t('releaseCommunications.user.markAllRead') }}
</v-btn>
</header>
<div
@@ -55,21 +71,25 @@
:key="update.id"
class="update-entry"
:class="{ 'update-entry-unread': !update.isRead, 'update-entry-highlight': update.id === highlightedId }"
@click="!update.isRead && store.markRead(update.id)"
>
<div class="update-meta">
<span>{{ update.category }}</span>
<span>{{ update.importance }}</span>
<time>{{ formatDate(update.publishedAt) }}</time>
</div>
<h2>{{ update.title }}</h2>
<p>{{ update.summary }}</p>
<div
v-if="update.body"
class="update-body"
>
{{ update.body }}
<h2>{{ updateTitle(update) }}</h2>
<div class="release-description">
<template
v-for="(block, index) in updateDescriptionBlocks(update)"
:key="index"
>
<p v-if="block.type === 'paragraph'">{{ block.text }}</p>
<ul v-else>
<li
v-for="item in block.items"
:key="item"
>
{{ item }}
</li>
</ul>
</template>
</div>
<time>{{ formatDate(update.publishedAt) }}</time>
</article>
<div
@@ -83,81 +103,83 @@
</template>
<style scoped>
@reference "@/assets/main.css";
.updates-page {
display: grid;
gap: 20px;
padding: 24px;
@apply mx-auto flex w-full max-w-6xl flex-col gap-5 px-5 py-8 md:px-8;
}
.updates-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
.page-header {
@apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
}
.eyebrow,
.update-meta {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
}
.updates-header h1 {
margin: 4px 0;
font-size: 1.75rem;
.page-header h1 {
@apply mt-2 text-3xl font-black md:text-4xl;
color: #172033;
}
.updates-header p {
margin: 0;
color: #64748b;
.page-header p {
@apply mt-2 max-w-3xl text-sm leading-6;
color: #526178;
}
.updates-list {
display: grid;
gap: 12px;
}
.update-entry {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 16px;
display: grid;
gap: 8px;
border-bottom: 1px solid #d8dee8;
padding: 18px 0;
}
.update-entry-unread {
border-color: rgb(var(--v-theme-primary));
box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary));
cursor: pointer;
.update-entry:first-child {
padding-top: 0;
}
.update-entry:last-of-type {
border-bottom: 0;
}
.update-entry-highlight {
outline: 2px solid rgb(var(--v-theme-primary));
}
.update-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
color: #64748b;
box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary));
padding-left: 12px;
}
.update-entry h2 {
margin: 0 0 6px;
font-size: 1.1rem;
}
.update-entry p {
margin: 0;
color: #334155;
font-size: 1.1rem;
font-weight: 800;
color: #172033;
}
.update-body {
margin-top: 12px;
color: #475569;
white-space: pre-line;
.release-description {
display: grid;
gap: 8px;
color: #334155;
font-size: 0.95rem;
line-height: 1.55;
}
.release-description p,
.release-description ul {
margin: 0;
}
.release-description ul {
padding-left: 20px;
}
.update-entry time {
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.page-message {

View File

@@ -3,20 +3,25 @@
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
import WorkspaceSelector from './WorkspaceSelector.vue';
import {
mdiCalendar,
mdiChevronDown,
mdiCogOutline,
mdiEyeOffOutline,
mdiFlagVariantOutline,
mdiFormatListBulleted,
mdiLogin,
mdiPlus,
mdiRefresh,
mdiTable,
} from '@mdi/js';
const route = useRoute();
const { t } = useI18n();
const authStore = useAuthStore();
const releaseCommunicationsStore = useReleaseCommunicationsStore();
const isContentViewMenuOpen = ref(false);
const contentViewActions = computed(() => {
@@ -104,6 +109,66 @@
icon: mdiPlus,
route: { name: 'channels', query: { create: 'true' } },
}];
case 'developer-release-notes':
return route.query.tab === 'release-notes'
? []
: [
{
key: 'refresh-release-commits',
label: t('releaseCommunications.commits.refresh'),
icon: mdiRefresh,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
refreshCommits: 'true',
},
},
},
{
key: 'exclude-release-commits',
label: t('releaseCommunications.commits.exclude'),
icon: mdiEyeOffOutline,
disabled: releaseCommunicationsStore.selectedCommitShas.length === 0,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
excludeCommits: 'true',
},
},
},
{
key: 'create-first-release',
label: t('releaseCommunications.developer.createFirstRelease'),
icon: mdiFlagVariantOutline,
disabled: releaseCommunicationsStore.selectedCommitShas.length !== 1,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
createFirstRelease: 'true',
},
},
},
{
key: 'create-release-note',
label: t('releaseCommunications.developer.createReleaseNote'),
icon: mdiPlus,
disabled: releaseCommunicationsStore.selectedCommitShas.length === 0,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
createReleaseNote: 'true',
},
},
},
];
case 'workspace-settings':
case 'settings-user-information':
case 'settings-workspaces':
@@ -179,17 +244,30 @@
</div>
</div>
<router-link
<template
v-for="action in appBarActions"
:key="action.key"
:to="action.route"
class="menu-action-link"
>
<button class="menu-item-action">
<button
v-if="action.disabled"
class="menu-item-action"
type="button"
disabled
>
<v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span>
</button>
</router-link>
<router-link
v-else
:to="action.route"
class="menu-action-link"
>
<button class="menu-item-action">
<v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span>
</button>
</router-link>
</template>
</div>
</div>
</nav>
@@ -256,6 +334,16 @@
color: #fffaf2;
}
.menu-item-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.menu-item-action:disabled:hover {
background: rgba(255, 255, 255, 0.8);
color: #172033;
}
.view-selector-option {
@apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition;
color: #172033;

View File

@@ -52,17 +52,20 @@
const collapsedSearchInputRef = ref(null);
const collapsedSearchPanelStyle = ref({});
const filterVisibleLinks = links =>
links.filter(link => !link.roles || authStore.hasAnyRole(link.roles));
const primaryLinks = [
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/developer/release-notes', labelKey: 'nav.releaseNotes', icon: mdiSourceCommit, roles: ['developer'], badge: 'commits' },
];
const bottomLinks = [
{ to: '/app/updates', labelKey: 'nav.whatsNew', icon: mdiBullhornOutline, badge: 'updates' },
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] },
{ to: '/app/developer/updates', labelKey: 'nav.releaseUpdates', icon: mdiBullhornOutline, roles: ['developer'] },
{ to: '/app/developer/release-commits', labelKey: 'nav.releaseCommits', icon: mdiSourceCommit, roles: ['developer'], badge: 'commits' },
];
const visiblePrimaryLinks = computed(() =>
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles))
);
const visiblePrimaryLinks = computed(() => filterVisibleLinks(primaryLinks));
const visibleBottomLinks = computed(() => filterVisibleLinks(bottomLinks));
const openSections = ref({
channels: false,
@@ -305,11 +308,14 @@
:title="!isExpanded ? 'Search' : null"
@click="openCollapsedSearch"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<v-text-field
v-if="isExpanded"
v-model="searchQuery"
class="sidebar-search-input"
:prepend-inner-icon="mdiMagnify"
placeholder="Search"
density="compact"
variant="plain"
@@ -329,11 +335,14 @@
v-if="!isExpanded"
class="sidebar-search sidebar-search-panel-input"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<v-text-field
ref="collapsedSearchInputRef"
v-model="searchQuery"
class="sidebar-search-input"
:prepend-inner-icon="mdiMagnify"
placeholder="Search"
density="compact"
variant="plain"
@@ -456,7 +465,7 @@
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section sidebar-primary-links">
<router-link
v-for="link in visiblePrimaryLinks"
:key="link.to"
@@ -655,6 +664,36 @@
</div>
<div
v-if="authStore.isAuthenticated && visibleBottomLinks.length"
class="sidebar-section sidebar-bottom-links"
>
<router-link
v-for="link in visibleBottomLinks"
:key="link.to"
:to="link.to"
class="sidebar-link"
active-class="sidebar-link-active"
:title="!isExpanded ? t(link.labelKey) : null"
>
<span class="sidebar-link-icon-wrap">
<v-icon :icon="link.icon" />
<span
v-if="link.badge === 'updates' && releaseCommunicationsStore.unreadCount"
class="sidebar-notification-badge"
>
{{ Math.min(releaseCommunicationsStore.unreadCount, 9) }}
</span>
</span>
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t(link.labelKey) }}
</span>
</router-link>
</div>
<SidebarUserMenu
v-if="authStore.isAuthenticated"
:is-expanded="isExpanded"
@@ -724,7 +763,11 @@
}
.sidebar-utilities {
@apply gap-3 pb-1;
@apply gap-3;
}
.sidebar-primary-links {
margin-top: -0.5rem;
}
.sidebar-search-wrap,
@@ -747,6 +790,7 @@
.sidebar-search-icon {
@apply h-5 w-5 flex-shrink-0 text-xl;
color: #526178;
}
.sidebar-search-input {
@@ -755,6 +799,10 @@
outline: none;
}
.sidebar-search-input :deep(.v-field__input) {
@apply min-h-0 p-0;
}
.sidebar-search-input::placeholder {
color: #7a8799;
}
@@ -866,6 +914,10 @@
@apply flex flex-col gap-2;
}
.sidebar-bottom-links {
@apply flex-shrink-0 pb-4;
}
.sidebar-section-header {
@apply flex items-center gap-2;
}
@@ -916,6 +968,7 @@
transform: rotate(180deg);
}
.sidebar-search :deep(.v-icon),
.sidebar-link :deep(.v-icon),
.sidebar-section-action :deep(.v-icon) {
@apply h-5 w-5 flex-shrink-0 text-xl;

View File

@@ -562,7 +562,7 @@
"feedbackReview": "Feedback Review",
"whatsNew": "What's New",
"releaseUpdates": "Release Updates",
"releaseCommits": "Release Commits",
"releaseNotes": "Release Notes",
"channels": "Channels",
"campaigns": "Campaigns",
"reviewQueue": "Review Queue",
@@ -595,9 +595,17 @@
"feedbackReporterCommented": "Reporter replied"
}
},
"releaseCommunications": {
"summary": "Summary",
"body": "Body",
"releaseCommunications": {
"summary": "Summary",
"description": "Description",
"noteTitle": "Title",
"english": "English",
"french": "French",
"titleEn": "English title",
"descriptionEn": "English description",
"titleFr": "French title",
"descriptionFr": "French description",
"body": "Body",
"category": "Category",
"importance": "Importance",
"audience": "Audience",
@@ -618,44 +626,59 @@
"developer": {
"eyebrow": "SaaS operator",
"title": "Release updates",
"description": "Create, publish, and archive curated product updates.",
"creationTitle": "Release Note",
"creationDescription": "Draft a release note from selected commits or edit an existing release.",
"createReleaseNote": "Create Release Note",
"createFirstRelease": "Create First Release",
"pastReleases": "Past releases",
"pastReleasesDescription": "Published, archived, and draft release notes.",
"noReleaseNotes": "No release notes yet.",
"newUpdate": "New update",
"publish": "Publish",
"archive": "Archive",
"pushEmail": "Push email",
"testMode": "Send to me only",
"confirmResend": "Confirm resend",
"sendEmail": "Send email",
"confirmEmail": "Send this release update by email?",
"emailResult": "Email sent to {count} recipient(s).",
"linkedCommits": "Linked commits",
"noLinkedCommits": "No commits linked to this update yet."
},
"commits": {
"eyebrow": "SaaS operator",
"title": "Release commits",
"description": "Import shipped commits and reconcile them with curated update entries.",
"title": "Release notes",
"description": "Review pending commits and maintain the release note history.",
"gitLogTab": "Changes",
"releaseNotesTab": "History",
"refresh": "Refresh",
"exclude": "Exclude",
"unreviewed": "unreviewed",
"branch": "Branch or ref",
"limit": "Limit",
"since": "Since",
"until": "Until",
"fetch": "Fetch commits",
"importJson": "Commit JSON payload",
"import": "Import commits",
"importResult": "Imported {imported}, updated {updated}, skipped {skipped}.",
"search": "Search",
"status": "Status",
"linkedUpdate": "Linked update",
"releaseNote": "Release note",
"notIncluded": "Not included",
"inclusion": {
"label": "Release note status",
"notIncluded": "Not in a release note",
"included": "In a release note",
"all": "All commits"
},
"author": "Author",
"sha": "SHA",
"commit": "Commit",
"committed": "Committed",
"actions": "Actions",
"empty": "No commits match the current filters.",
"clear": "Clear",
"link": "Update",
"selected": "{count} selected",
"selectCommit": "Select commit",
"copy": "Copy",
"copySelected": "Copy selected",
"copied": "Copied.",
"clearSelection": "Clear selection",
"createUpdate": "Create update entry",
"createUpdate": "Create release note",
"selectedCommits": "Selected commits",
"internalOnly": "Internal only",
"ignore": "Ignore"
}

View File

@@ -562,7 +562,7 @@
"feedbackReview": "Revue feedback",
"whatsNew": "Nouveautés",
"releaseUpdates": "Mises à jour",
"releaseCommits": "Commits release",
"releaseNotes": "Notes de version",
"channels": "Canaux",
"campaigns": "Campagnes",
"reviewQueue": "File de révision",
@@ -595,9 +595,17 @@
"feedbackReporterCommented": "Réponse du rapporteur"
}
},
"releaseCommunications": {
"summary": "Résumé",
"body": "Détail",
"releaseCommunications": {
"summary": "Résumé",
"description": "Description",
"noteTitle": "Titre",
"english": "Anglais",
"french": "Français",
"titleEn": "Titre anglais",
"descriptionEn": "Description anglaise",
"titleFr": "Titre français",
"descriptionFr": "Description française",
"body": "Détail",
"category": "Catégorie",
"importance": "Importance",
"audience": "Audience",
@@ -618,44 +626,59 @@
"developer": {
"eyebrow": "Opérateur SaaS",
"title": "Mises à jour release",
"description": "Créez, publiez et archivez les mises à jour produit rédigées.",
"creationTitle": "Note de release",
"creationDescription": "Rédigez une note depuis les commits sélectionnés ou modifiez une release existante.",
"createReleaseNote": "Créer une note de release",
"createFirstRelease": "Créer la première release",
"pastReleases": "Releases passées",
"pastReleasesDescription": "Notes de release publiées, archivées et brouillons.",
"noReleaseNotes": "Aucune note de release pour le moment.",
"newUpdate": "Nouvelle mise à jour",
"publish": "Publier",
"archive": "Archiver",
"pushEmail": "Email push",
"testMode": "M'envoyer seulement",
"confirmResend": "Confirmer le renvoi",
"sendEmail": "Envoyer email",
"confirmEmail": "Envoyer cette mise à jour par email?",
"emailResult": "Email envoyé à {count} destinataire(s).",
"linkedCommits": "Commits liés",
"noLinkedCommits": "Aucun commit lié à cette mise à jour."
},
"commits": {
"eyebrow": "Opérateur SaaS",
"title": "Commits release",
"description": "Importez les commits livrés et associez-les aux mises à jour rédigées.",
"title": "Notes de release",
"description": "Révisez les commits en attente et gérez l'historique des notes de release.",
"gitLogTab": "Changements",
"releaseNotesTab": "Historique",
"refresh": "Actualiser",
"exclude": "Exclure",
"unreviewed": "non révisés",
"branch": "Branche ou ref",
"limit": "Limite",
"since": "Depuis",
"until": "Jusqu'au",
"fetch": "Récupérer commits",
"importJson": "Payload JSON de commits",
"import": "Importer commits",
"importResult": "{imported} importés, {updated} mis à jour, {skipped} ignorés.",
"search": "Recherche",
"status": "Statut",
"linkedUpdate": "Mise à jour liée",
"releaseNote": "Note de release",
"notIncluded": "Non inclus",
"inclusion": {
"label": "Statut de note",
"notIncluded": "Non inclus dans une note",
"included": "Inclus dans une note",
"all": "Tous les commits"
},
"author": "Auteur",
"sha": "SHA",
"commit": "Commit",
"committed": "Commité",
"actions": "Actions",
"empty": "Aucun commit ne correspond aux filtres actuels.",
"clear": "Effacer",
"link": "Mise à jour",
"selected": "{count} sélectionnés",
"selectCommit": "Sélectionner le commit",
"copy": "Copier",
"copySelected": "Copier sélection",
"copied": "Copié.",
"clearSelection": "Effacer sélection",
"createUpdate": "Créer une entrée",
"createUpdate": "Créer la note de release",
"selectedCommits": "Commits sélectionnés",
"internalOnly": "Interne seulement",
"ignore": "Ignorer"
}

View File

@@ -2,34 +2,17 @@ import { createApp } from 'vue';
import App from './App.vue';
import router from '@/router/router.js';
import { createPinia } from 'pinia';
import './assets/main.css';
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import { aliases, mdi } from 'vuetify/iconsets/mdi-svg';
import {
VAlert,
VApp,
VBtn,
VCheckbox,
VCheckboxBtn,
VDialog,
VFileInput,
VForm,
VIcon,
VProgressCircular,
VProgressLinear,
VRadio,
VRadioGroup,
VSelect,
VSnackbar,
VTextarea,
VTextField,
} from 'vuetify/components';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import vueGoogleOauth from 'vue3-google-login';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
import Toast, { POSITION } from 'vue-toastification';
import 'vue-toastification/dist/index.css';
import './assets/main.css';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useReviewQueueStore } from '@/features/reviews/stores/reviewQueueStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
@@ -44,26 +27,8 @@ import { createHead } from '@vueuse/head';
import { socializeTheme } from '@/plugins/theme.js';
const vuetify = createVuetify({
components: {
VDialog,
VApp,
VBtn,
VCheckbox,
VCheckboxBtn,
VFileInput,
VProgressLinear,
VProgressCircular,
VIcon,
VRadio,
VRadioGroup,
VSelect,
VTextField,
VSnackbar,
VForm,
VTextarea,
VAlert,
},
directives: {},
components,
directives,
icons: {
defaultSet: 'mdi',
aliases,

View File

@@ -34,7 +34,6 @@ const MyFeedbackDetailView = () => import('@/features/feedback/views/MyFeedbackD
const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.vue');
const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue');
const UpdatesView = () => import('@/features/release-communications/views/UpdatesView.vue');
const DeveloperUpdatesView = () => import('@/features/release-communications/views/DeveloperUpdatesView.vue');
const DeveloperReleaseCommitsView = () => import('@/features/release-communications/views/DeveloperReleaseCommitsView.vue');
const routes = [
@@ -135,12 +134,17 @@ const routes = [
{
path: '/app/developer/updates',
name: 'developer-release-updates',
component: DeveloperUpdatesView,
redirect: { name: 'developer-release-notes' },
meta: { requiresAuth: true, roles: ['developer'] },
},
{
path: '/app/developer/release-commits',
name: 'developer-release-commits',
redirect: { name: 'developer-release-notes' },
meta: { requiresAuth: true, roles: ['developer'] },
},
{
path: '/app/developer/release-notes',
name: 'developer-release-notes',
component: DeveloperReleaseCommitsView,
meta: { requiresAuth: true, roles: ['developer'] },
},