gauth-0004-create-more-audiences-and-categories · frontend/src
Spec de développement pour le design Nudges - Contexte Réel du Code (Ant Design).dc.html. Repo : RandomCoffee/randomcoffee · branche master · frontend frontend/src.
Deux livrables distincts :
2a–2g du design).1a–1k), indépendants les uns des autres, livrables séparément.Principe directeur : aucun nouveau concept, aucun nouvel écran. Neuf nudges sur onze réutilisent une chaîne gettext() déjà traduite et un composant déjà monté sur la page. Deux seulement demandent du backend.
Trois composants nouveaux au total. Tout le reste est de la composition avec l'existant.
<AudienceSuggestionCards /> — nouveaufrontend/src/components/audience-suggestions/index.tsx
Grille de cartes d'audiences proposées, cochables. Utilisé par 1a, 1h, 2d.
type AudienceSuggestion = {
key: string; // stable, pour la sélection
name: string; // "New joiners"
type: AUDIENCES_TYPES; // DYNAMIC par défaut
filters: Filters; // format filters existant, cf. utils/filters
description: string; // règle en langage naturel : "Office is Paris"
count: number | null; // null = en cours de calcul
source?: 'csv' | 'hris' | 'categories';
};
type Props = {
suggestions: AudienceSuggestion[];
value: string[]; // keys cochées
onChange: (keys: string[]) => void;
loading?: boolean;
columns?: number; // défaut 3
};
Composition Ant : Row gutter={[8,8]} + Col + Card size="small" + Checkbox + Tag icon={<SyncOutlined />} pour le type + Text type="secondary" pour le compte.
Comptes : un seul appel groupé sur get_filters_count par suggestion (même service que CategoriesTreeSelect), avec Skeleton.Button tant que count === null. Ne pas bloquer le rendu des cartes sur les comptes.
<AudienceEmptyHint /> — nouveaufrontend/src/components/audience-empty-hint/index.tsx
Le bloc Empty + CTA réutilisé dans tous les états vides (1c, 1d, 1j). Existe déjà en substance dans components/audience-select/index.tsx (notFoundContent) — extraire ce JSX plutôt que le réécrire.
type Props = {
kind: 'categories' | 'audiences';
layout?: 'dropdown' | 'inline'; // dropdown = centré, inline = horizontal
onCreate?: () => void; // défaut : history.push vers la route de création
};
Copy : réutiliser mot pour mot les chaînes existantes, aucune nouvelle traduction.
| kind | titre | description | bouton |
|---|---|---|---|
categories | No categories for now | Categories let you categorize your coworkers by things like departments and location. | Create New Category |
audiences | No audience found | You've got no audiences for the moment | Create a new Audience |
Les trois premières viennent de pages/account/admin/categories/empty.tsx, les autres de audience-select/index.tsx et admin/audiences/index.tsx.
Gate permissions : envelopper le bouton dans <PermissionAction hasPermission={permissions.hasCreateAudiencesPermission()}> pour le kind audiences — même traitement que dans audience-select.
hasAudiences(state) — nouveau sélecteurfrontend/src/utils/onboarding.ts
export function hasAudiences(state) {
return state.audiences.pagination.count > 0;
}
Utilisé par 1g (milestone) et 1i (condition d'affichage de l'Alert). Un seul point de vérité, à ne pas dupliquer.
Fichiers touchés :
pages/account/admin/users/add/csv/index.tsx écrans 2a, 2c, 2d
pages/account/admin/users/add/csv/mapping_item.tsx écran 2b
pages/account/admin/users/list/audience-filters.tsx écran 2e
utils/upload.ts dérivation des suggestions
Aucun changement. Rappel utile pour la suite : Papa.parse lit le fichier côté client dès le dépôt (beforeUpload retourne false). Les colonnes et leurs valeurs sont donc connues avant toute requête serveur — c'est ce qui rend les suggestions des écrans suivants calculables sans backend.
Fichier : mapping_item.tsx, dans NewFieldsOptions.
Ajouter sous les champs existants, uniquement quand mapping.status === 'NEW' et mapping.type est dans [select, boolean, date] :
<Alert
type="info"
showIcon
message={interpolate(
gettext('%(count)s values detected. This category can become a matching rule.'),
{ count: uniqueValues.length },
)}
description={
<Checkbox
checked={mapping.suggestAudiences !== false}
onChange={(e) => updateMapping({ ...mapping, suggestAudiences: e.target.checked })}
>
{gettext('Suggest audiences from this column after the import')}
</Checkbox>
}
/>
uniqueValues : uniq(dataSource.map(row => row[mapping.field])), déjà disponible via la prop dataSource du MappingItem.suggestAudiences : nouveau champ local sur l'objet mapping, ajouté par formatCsvFields avec true par défaut. Il ne part pas au serveur : à filtrer dans getUsersUploadObj.Aucun changement.
Fichier : csv/index.tsx, composant UploadStatus.
Aujourd'hui extra ne contient que « Go to my upload history » et « Resubmit a new list ». Nouvelle structure :
<Result
status="success"
title={/* inchangé */}
subTitle={/* inchangé */}
extra={[
<AudienceSuggestionBlock
key="audiences"
categories={createdCategories}
suggestions={suggestions}
/>,
...existingButtons, // conservés, en secondaire
]}
/>
AudienceSuggestionBlock = Alert type="info" (le titre et la promesse) + <AudienceSuggestionCards /> + un Button type="primary" dont le libellé porte le compte :
interpolate(ngettext('Create %(count)s audience', 'Create %(count)s audiences', n), { count: n })
Dérivation des suggestions — à ajouter dans utils/upload.ts, fonction pure, testable sans réseau :
export function getAudienceSuggestions(csvData, mappings): AudienceSuggestion[]
Règles, dans cet ordre de priorité, plafonné à 3 suggestions :
| Condition sur le mapping | Suggestion produite | filters |
|---|---|---|
type === 'date' et titre matche start/hire/joining | New joiners | date, delta 90 jours |
type === 'select' avec ≤ 8 valeurs distinctes | une par valeur, la plus peuplée d'abord | eq sur la valeur |
type === 'select' avec > 8 valeurs | aucune (trop bruyant) | — |
colonne manager_email / manager non vide | Managers | présence d'au moins un rattachement |
Ne suggérer que les colonnes dont suggestAudiences !== false. Toutes les suggestions sont AUDIENCES_TYPES.DYNAMIC.
Séquencement à respecter : onUploadOver dispatch déjà categories/getCategories. Les suggestions doivent être calculées après ce dispatch, sinon les IDs de catégories fraîchement créées ne sont pas encore dans le store et les filters pointent dans le vide.
Tracking : invite:csv:audience:suggested { count } à l'affichage du bloc, users:audiences:created { type: 1, source: 'csv' } à la création — le second existe déjà dans audience-filters.tsx, ajouter simplement la clé source.
Réutiliser AudienceModal de pages/account/admin/users/list/audience-filters.tsx tel quel. Deux extensions :
initialValues={{ name, type, filters }} pour arriver pré-rempli — le composant lit aujourd'hui les filtres depuis useSelector(({ users }) => users.filters), il faut permettre de les passer en prop, avec fallback sur le store.Checkbox sous les RadioCards : « Use it right away in a program ». Cochée, la redirection après création va vers /admin/campaigns/new avec l'audience pré-sélectionnée au lieu de /admin/users/audiences.Un seul modal, même si plusieurs audiences sont cochées. Il porte alors le nom de la première et un Text type="secondary" « + 1 other audience will be created with the same settings ». Enchaîner N modals identiques est le principal risque d'abandon de ce flow.
Aucun changement de code. Le toast (Your audience has been created successfully) et la redirection existent déjà dans onSaveAudience. Seule différence observable : la liste n'est plus vide et le milestone 1g se coche.
Aucun changement de code. À vérifier en recette : l'audience créée apparaît bien dans AudiencesSelect du builder sans rechargement (le composant maintient un audiences_cache — vérifier que audiences/addToCache est bien alimenté après création).
Couvert par la Partie A (§A.4).
Couvert par la Partie A (§A.2).
CategoriesTreeSelect sans état videcomponents/categories-tree-select/index.tsx
Le TreeSelect n'a pas de notFoundContent. Ajouter :
notFoundContent={
tree.length === 0
? <AudienceEmptyHint kind="categories" layout="dropdown" />
: undefined
}
Le composant voisin AudiencesSelect a déjà exactement ce pattern — s'aligner dessus, ne pas inventer.
pages/account/admin/campaigns/rules/by-category.tsx et rules/audiences.tsx
by-category.tsx : le CategoriesSelector filtre les catégories par type puis les mappe. Si categories.length === 0 après filtrage, passer notFoundContent={<AudienceEmptyHint kind="categories" layout="inline" />} sur le Select.audiences.tsx : les deux AudiencesSelect n'affichent leur Empty + CTA qu'après une recherche infructueuse (condition query && !loading). Étendre la condition pour couvrir le cas « zéro audience à l'ouverture » : (query || audiences.length === 0) && !loading.Le second RuleSelector reste volontairement vide tant qu'aucune catégorie n'est choisie — c'est correct, ne pas y toucher.
PeopleCount : l'avertissement devient actionnablecomponents/categories-tree-select/index.tsx, export PeopleCount
Le composant accepte déjà une prop onClick, mais elle n'est pas exploitée dans la branche warning. Dans cette branche, rendre un Alert type="warning" au lieu du div nu, avec :
No corresponding profiles found,Button size="small" de repli par filtre isolé, si le back peut renvoyer les comptes partiels (sinon, phase 2),Typography.Link « Save this filter as an audience instead ».Un seul correctif ici couvre le ciblage de campagne, les broadcasts et les permissions : le composant est réutilisé par CategoriesTreeSelect et AudiencesSelect.
pages/account/admin/users/list/selected_users_alert.tsx
La création d'audience est aujourd'hui enterrée dans ActionsDropdown. Ajouter un Typography.Link « Save as an audience » directement dans la barre, après « Select all coworkers », séparé par le Divider type="vertical" déjà en place. Il ouvre l'AudienceModal existant, avec le nom pré-rempli depuis le filtre actif si un seul filtre est posé.
Ne pas retirer l'entrée du dropdown : les deux chemins coexistent.
components/onboarding/admin-milestones.tsx + utils/onboarding.ts
hasAudiences (§0.3) et l'insérer dans adminCheckers — le compteur passe automatiquement à x / 4 puis x / 8 avec les checkers end-user, onboardingPercent n'a pas besoin d'être touché.getMilestones(), en 3ᵉ position, avant « Launch your first campaign » : c'est l'ordre d'usage réel du produit, et ça rend le ciblage disponible au moment où l'admin ouvre le builder.title: Create your first audience
helpText: Audiences let you reuse a group of coworkers — "New joiners", "Paris office",
"Managers" — across every program and broadcast, instead of picking people one by one.
actionText: Create an audience
onAction: history.push(`/account/${slug}/admin/users/audiences`)
key: 'audiences'
done: hasAudiences
hasAudiences(state) à la condition admin de hasFinishedOnboarding.onboarding:add:audiences:click, calqué sur onboarding:add:categories:click.Bonus quasi gratuit : getOnboardingSteps() mappe déjà la route /admin/users/audiences vers ONBOARDING_MODAL_TYPES.AUDIENCES. Le canal des modal steps existe et n'a simplement pas de contenu côté données — un step d'explication peut être créé sans une ligne de front.
pages/account/admin/audiences/index.tsx
emptyOptions du NamespaceTable : garder la description existante et ajouter <AudienceSuggestionCards /> alimenté par les catégories du store (categories.data), avec la même dérivation qu'en §A.4 mais appliquée aux valeurs de catégories déjà en base.onAddAudienceClick fait history.push('/account/${slug}/users') — l'admin est éjecté vers une liste sans explication. Le remplacer par l'ouverture de l'AudienceModal, ou à défaut par /admin/users (liste admin) avec un message.info expliquant qu'il faut filtrer puis enregistrer.pages/account/admin/campaigns/templates.tsx
Le seul nudge produit déjà en place est l'Alert type="info" showIcon action={<CreateCampaignButton />} (« Find your new team ritual with Programs »). C'est le format de référence : décliner le même composant, sous condition !hasAudiences(state) && hasCategories(state), avec icon={<TeamOutlined />} et un Button secondaire « Create audience ».
Une seule Alert visible à la fois : si celle des programmes s'affiche déjà, ne pas empiler.
**pages/account/admin/users/list/categories-header.tsx** — le <Dropdown disabled={categories.length === 0}> rend le bouton œil inerte, sans explication. L'envelopper dans un Tooltip conditionnel :
Create a category to show extra columns here — for example Department or Office.
**pages/account/admin/settings/permissions/edit.tsx** — la section « Audiences » est souvent le premier endroit où un admin lit le mot « audience ». Sous le champ « Authorized audiences: », si zéro audience existe, afficher un Alert type="info" avec une définition en une phrase et un lien de création. Le Select reste en place, vide.
pages/account/admin/settings/hrsi.tsx (front) + chemin mass_upload / create_categories (back)
C'est le nudge le plus coûteux, à traiter en dernier. Le back crée déjà les catégories en silence pour toutes les sources (CSV, HRIS, SCIM) au même endroit — un seul point d'accroche couvre les trois.
{ category_id, category_title, new_values: [{ value, count }] }.Alert type="info" sur la page d'intégration, avec les valeurs en Tag et un Button « Create audiences » qui ouvre AudienceSuggestionCards pré-rempli.onboarding ou ui persisté). Sans ce plafond, la bannière devient du bruit à chaque arrivée d'employé.L'écran HRIS promet déjà d'ajouter les nouveaux qualifiés aux campagnes « selon des critères d'audience ». La promesse est faite, l'outil pour la tenir n'est pas proposé.
| Lot | Contenu | Coût | Dépendances |
|---|---|---|---|
| 1 | AudienceEmptyHint + branchements 1c, 1d, 1j | faible — copy existante | aucune |
| 2 | 1f lien dans la barre de sélection, 1e alerte actionnable | faible | aucune |
| 3 | hasAudiences + 1g milestone + 1i Alert | faible, front seul | §0.3 |
| 4 | AudienceSuggestionCards + Partie A complète + 1h | moyen | §0.1, getAudienceSuggestions |
| 5 | 1k bannière de sync | fort — backend requis | diff de valeurs côté API |
.po. Toute chaîne réellement nouvelle passe par gettext() / interpolate() et par une PR de traduction FR.PermissionAction + permissions.hasCreateAudiencesPermission(). Un admin sans le droit doit voir le message, pas le bouton actif.get_filters_count : Skeleton ou Spin local, comme le fait déjà PeopleCount.1i sur la même page.contexte:objet:action et toujours porter le type d'audience et la source dans les propriétés, pour pouvoir mesurer quel nudge convertit.