Merge branch 'feat/scheduled-records-hint' into 'main'
feat(records): scheduled records empty-state hint See merge request 2-6/2-6-4/terravault/ordinis!222
This commit is contained in:
@@ -756,3 +756,18 @@ export const NOTIFICATION_EVENT_TYPES = [
|
||||
'RecordDraftWithdrawn',
|
||||
] as const
|
||||
export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
|
||||
|
||||
/**
|
||||
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
|
||||
* {@code validFrom > now AND validTo > now} в текущем scope view.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code count=0} → frontend hint не показывает.</li>
|
||||
* <li>{@code nearestValidFrom} — ISO datetime ближайшей запланированной;
|
||||
* используется в CTA «Перейти к дате» (time-travel).</li>
|
||||
* </ul>
|
||||
*/
|
||||
export type ScheduledRecordsSummary = {
|
||||
count: number
|
||||
nearestValidFrom: string | null
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type RecordDependentsPage,
|
||||
type RecordResponse,
|
||||
type ReviewQueuePage,
|
||||
type ScheduledRecordsSummary,
|
||||
type SchemaDependent,
|
||||
type SearchResponse,
|
||||
type WebhookDeliveryPage,
|
||||
@@ -784,6 +785,37 @@ export const useRecords = (
|
||||
scopeCsv: string,
|
||||
filter?: RecordsFilter,
|
||||
) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter))
|
||||
|
||||
/**
|
||||
* Scheduled records hint для empty-state. Fetch'ит count записей с
|
||||
* validFrom > now (но validTo > now — т.е. не сразу закрытые). Показывается
|
||||
* пользователю когда список пуст, но в будущем что-то запланировано.
|
||||
*
|
||||
* <p>Fixes UX bug: user создал запись с validFrom=18.05, открыл catalog 15.05 —
|
||||
* пустая таблица, никаких хинтов. Теперь UI показывает «Запланировано: 1
|
||||
* запись на 18.05 [перейти к дате]».
|
||||
*
|
||||
* <p>Stale 30s — scheduled state changes медленно. Refetch on focus = true
|
||||
* (default) — пользователь возвращается во вкладку → fresh count.
|
||||
*/
|
||||
export const scheduledSummaryQuery = (dictionaryName: string, scopeCsv: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['scheduled-summary', dictionaryName, scopeCsv] as const,
|
||||
queryFn: async (): Promise<ScheduledRecordsSummary> => {
|
||||
const { data } = await apiClient.get<ScheduledRecordsSummary>(
|
||||
`/${dictionaryName}/records/scheduled-summary`,
|
||||
{ params: { as_scope: scopeCsv } },
|
||||
)
|
||||
return data
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
export const useScheduledSummary = (dictionaryName: string, scopeCsv: string) =>
|
||||
useQuery({
|
||||
...scheduledSummaryQuery(dictionaryName, scopeCsv),
|
||||
enabled: Boolean(dictionaryName),
|
||||
})
|
||||
export const useRecordRaw = (
|
||||
dictionaryName: string,
|
||||
businessKey: string | undefined,
|
||||
|
||||
@@ -349,6 +349,15 @@ i18n
|
||||
'dict.list.fk.usedBy_many': '← {{count}} использ.',
|
||||
'dict.list.fk.usedBy_other': '← {{count}} использ.',
|
||||
'dict.empty': 'В этом справочнике пока нет записей',
|
||||
'dict.scheduled.hint_one':
|
||||
'Запланирована {{count}} запись на {{date}}',
|
||||
'dict.scheduled.hint_few':
|
||||
'Запланировано {{count}} записи, ближайшая на {{date}}',
|
||||
'dict.scheduled.hint_many':
|
||||
'Запланировано {{count}} записей, ближайшая на {{date}}',
|
||||
'dict.scheduled.hint_other':
|
||||
'Запланировано {{count}} записей, ближайшая на {{date}}',
|
||||
'dict.scheduled.cta': 'Перейти к этой дате',
|
||||
'dict.col.businessKey': 'Бизнес-ключ',
|
||||
'dict.col.scope': 'Scope',
|
||||
'dict.col.validFrom': 'Действует с',
|
||||
@@ -1151,6 +1160,11 @@ i18n
|
||||
'dict.list.fk.usedBy_one': '← {{count}} used by',
|
||||
'dict.list.fk.usedBy_other': '← {{count}} used by',
|
||||
'dict.empty': 'No records in this dictionary yet',
|
||||
'dict.scheduled.hint_one':
|
||||
'{{count}} record scheduled for {{date}}',
|
||||
'dict.scheduled.hint_other':
|
||||
'{{count}} records scheduled, earliest on {{date}}',
|
||||
'dict.scheduled.cta': 'Jump to that date',
|
||||
'dict.col.businessKey': 'Business key',
|
||||
'dict.col.scope': 'Scope',
|
||||
'dict.col.validFrom': 'Valid from',
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
useDictPendingDrafts,
|
||||
useRecordRaw,
|
||||
useRecords,
|
||||
useScheduledSummary,
|
||||
type RecordsFilter,
|
||||
} from '@/api/queries'
|
||||
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
|
||||
@@ -169,6 +170,16 @@ function DictionaryDetail() {
|
||||
}
|
||||
: undefined
|
||||
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
|
||||
/**
|
||||
* Scheduled records hint (post-Phase-B-2 UX fix). Когда records list пустой
|
||||
* на момент now / time-travel, но в БД есть записи с validFrom > now,
|
||||
* показываем «Запланировано N, ближайшая на DD.MM [Перейти к этой дате]».
|
||||
* Fixes user feedback: «создал запись на 18.05 — её не видно».
|
||||
*
|
||||
* <p>Fetch unconditional (cheap single-aggregate query). Frontend сам решает
|
||||
* показывать hint или нет по {@code count > 0}.
|
||||
*/
|
||||
const scheduledSummary = useScheduledSummary(name, 'PUBLIC,INTERNAL,RESTRICTED')
|
||||
/**
|
||||
* Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
|
||||
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если
|
||||
@@ -974,7 +985,30 @@ function DictionaryDetail() {
|
||||
)}
|
||||
|
||||
{recordsResult.data && recordsResult.data.length === 0 && (
|
||||
<EmptyState title={t('dict.empty')} />
|
||||
<EmptyState
|
||||
title={t('dict.empty')}
|
||||
description={
|
||||
scheduledSummary.data && scheduledSummary.data.count > 0 && scheduledSummary.data.nearestValidFrom
|
||||
? t('dict.scheduled.hint', {
|
||||
count: scheduledSummary.data.count,
|
||||
date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
action={
|
||||
scheduledSummary.data && scheduledSummary.data.count > 0 && scheduledSummary.data.nearestValidFrom
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTimeTravelAt(scheduledSummary.data!.nearestValidFrom!)}
|
||||
className="text-cell text-accent hover:underline"
|
||||
>
|
||||
{t('dict.scheduled.cta')}
|
||||
</button>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{recordsResult.data && recordsResult.data.length > 0 && (
|
||||
|
||||
+25
@@ -178,4 +178,29 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
|
||||
@Param("from") OffsetDateTime from,
|
||||
@Param("to") OffsetDateTime to,
|
||||
@Param("granularity") String granularity);
|
||||
|
||||
/**
|
||||
* Scheduled records summary: записи которые ещё не активны (validFrom > now),
|
||||
* но запланированы на будущее (validTo > now — т.е. не сразу закрытые).
|
||||
* Используется empty-state hint в admin-UI: «У словаря есть N запланированных
|
||||
* на DD.MM» — fixes UX bug когда пользователь создаёт запись с validFrom
|
||||
* на будущую дату и не видит её в списке.
|
||||
*
|
||||
* <p>Single query → returns {@code [count Long, nearestValidFrom OffsetDateTime]}.
|
||||
* Если scheduled нет — count=0, nearest=null.
|
||||
*
|
||||
* <p>Filter по scope тот же что в read-side listActive: respects user's
|
||||
* view permissions. Anonymous (только PUBLIC) → INTERNAL scheduled не учитывается.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COUNT(r), MIN(r.validFrom) FROM DictionaryRecord r
|
||||
WHERE r.dictionaryId = :dictionaryId
|
||||
AND r.dataScope IN :allowedScopes
|
||||
AND r.validFrom > :now
|
||||
AND r.validTo > :now
|
||||
""")
|
||||
List<Object[]> findScheduledSummary(
|
||||
@Param("dictionaryId") UUID dictionaryId,
|
||||
@Param("allowedScopes") Collection<DataScope> allowedScopes,
|
||||
@Param("now") OffsetDateTime now);
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package cloud.nstart.terravault.ordinis.readapi.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Empty-state hint payload: сколько записей запланировано в будущем и
|
||||
* на какую ближайшую дату. Используется admin-UI чтобы показать
|
||||
* «У словаря есть N запланированных, ближайшая на DD.MM» вместе с
|
||||
* CTA «Перейти к дате» (set time-travel).
|
||||
*
|
||||
* <p>{@code count == 0} → {@code nearestValidFrom == null}; frontend
|
||||
* не показывает hint в этом случае.
|
||||
*
|
||||
* <p>Scope filtering: same view permissions that {@code listActive}
|
||||
* использует — INTERNAL scheduled не учитывается для PUBLIC consumer'а.
|
||||
*/
|
||||
public record ScheduledRecordsSummary(long count, OffsetDateTime nearestValidFrom) {
|
||||
|
||||
public static ScheduledRecordsSummary empty() {
|
||||
return new ScheduledRecordsSummary(0L, null);
|
||||
}
|
||||
}
|
||||
+40
@@ -6,6 +6,7 @@ import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRep
|
||||
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
||||
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
|
||||
import cloud.nstart.terravault.ordinis.readapi.dto.ScheduledRecordsSummary;
|
||||
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener;
|
||||
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
|
||||
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
|
||||
@@ -172,6 +173,45 @@ public class RecordReadService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled records summary: подсчитать записи у которых validFrom в будущем
|
||||
* (но validTo тоже в будущем — т.е. не сразу закрытые). Используется
|
||||
* admin-UI empty-state hint: «У dict есть N запланированных, ближайшая на DD.MM».
|
||||
*
|
||||
* <p>Scope rules:
|
||||
* <ul>
|
||||
* <li>Если scope словаря недоступен — 404 (NoSuchElementException), как в listActive</li>
|
||||
* <li>Иначе counts только записи в {@code allowedScopes} (INTERNAL не виден PUBLIC consumer'у)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Empty result (count=0) — нормальное состояние, возвращается явный
|
||||
* {@link ScheduledRecordsSummary#empty()} (не null) для уменьшения NPE-риска
|
||||
* на frontend.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ScheduledRecordsSummary scheduledSummary(
|
||||
String dictionaryName, List<DataScope> allowedScopes) {
|
||||
|
||||
DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
|
||||
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
|
||||
|
||||
if (!allowedScopes.contains(def.getScope())) {
|
||||
throw new ScopeAccessDeniedException(
|
||||
"Scope " + def.getScope() + " not in allowed scopes " + allowedScopes);
|
||||
}
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
List<Object[]> rows = recordRepository.findScheduledSummary(
|
||||
def.getId(), allowedScopes, now);
|
||||
if (rows.isEmpty()) return ScheduledRecordsSummary.empty();
|
||||
|
||||
Object[] row = rows.get(0);
|
||||
long count = row[0] == null ? 0L : ((Number) row[0]).longValue();
|
||||
OffsetDateTime nearest = (OffsetDateTime) row[1];
|
||||
if (count == 0L || nearest == null) return ScheduledRecordsSummary.empty();
|
||||
return new ScheduledRecordsSummary(count, nearest);
|
||||
}
|
||||
|
||||
public static class ScopeAccessDeniedException extends RuntimeException {
|
||||
public ScopeAccessDeniedException(String message) { super(message); }
|
||||
}
|
||||
|
||||
+22
@@ -3,6 +3,7 @@ package cloud.nstart.terravault.ordinis.readapi.web;
|
||||
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
|
||||
import cloud.nstart.terravault.ordinis.readapi.dto.ScheduledRecordsSummary;
|
||||
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService;
|
||||
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
|
||||
import cloud.nstart.terravault.ordinis.readapi.spatial.GeoJsonPolygon;
|
||||
@@ -105,6 +106,27 @@ public class DictionaryReadController {
|
||||
return new ResponseEntity<>(items, headers, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled records summary: empty-state hint для admin-UI. Возвращает
|
||||
* {@code {count, nearestValidFrom}} для записей с {@code validFrom > now AND validTo > now}
|
||||
* в текущем scope view.
|
||||
*
|
||||
* <p>Use case: пользователь создаёт запись с {@code validFrom} на будущее (e.g. 18.05),
|
||||
* затем заходит в catalog 15.05 — табличка пустая. Frontend читает этот endpoint
|
||||
* и показывает «У словаря есть N запланированных, ближайшая на DD.MM. [Перейти к дате]».
|
||||
*
|
||||
* <p>{@code count=0} → frontend ничего не показывает. Дешёвый single query, OK к poll'ить
|
||||
* с те же refetchInterval'ом что и records list.
|
||||
*/
|
||||
@GetMapping("/scheduled-summary")
|
||||
public ScheduledRecordsSummary scheduledSummary(
|
||||
@PathVariable String dictionaryName,
|
||||
@RequestParam(value = "as_scope", required = false) String asScope) {
|
||||
|
||||
List<DataScope> scopes = scopeContext.resolveForRead(asScope).stream().toList();
|
||||
return service.scheduledSummary(dictionaryName, scopes);
|
||||
}
|
||||
|
||||
/** Bbox parse errors → 400, не 500. Ловится в {@code ReadApiExceptionHandler}. */
|
||||
public static class BadBboxException extends RuntimeException {
|
||||
public BadBboxException(String message) { super(message); }
|
||||
|
||||
Reference in New Issue
Block a user