Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions plans/atlas-data-sources-access-state.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions plugins/atlas/src/data-sources/AccessStatusBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<span class="access-status" :class="`access-status--${visualState}`" :title="tooltip">
<span class="access-status__icon" aria-hidden="true">{{ icon }}</span>
<span>{{ label }}</span>
</span>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { DataSourceAccessState } from './types';

const props = defineProps<{ state: DataSourceAccessState }>();

const visualState = computed(() => props.state === 'write' ? 'read' : props.state);
const label = computed(() => {
switch (visualState.value) {
case 'read': return 'Access';
case 'pending': return 'Pending access';
case 'restricted': return 'Restricted';
default: return 'No access';
}
});
const icon = computed(() => {
switch (visualState.value) {
case 'read': return '✓';
case 'pending': return '◷';
case 'restricted': return '⊘';
default: return '▣';
}
});
const tooltip = computed(() => visualState.value === 'restricted'
? 'Access to this dataset is restricted. Contact your administrator to gain access.'
: label.value);
</script>
26 changes: 26 additions & 0 deletions plugins/atlas/src/data-sources/DataSourceCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<template>
<button class="data-source-card" type="button" @click="$emit('select', source.id)">
<div class="data-source-card__topline">
<span class="visibility-badge">{{ visibilityLabel }}</span>
<AccessStatusBadge v-if="showAccessStatus && source.accessState" :state="source.accessState" />
</div>
<h2>{{ source.datasetDetail.name }}</h2>
<p>{{ source.datasetDetail.summary || source.datasetDetail.description || 'No description available.' }}</p>
<dl>
<div><dt>Data model</dt><dd>{{ source.dataModel || 'Not specified' }}</dd></div>
<div><dt>Subjects</dt><dd>{{ source.totalSubjects?.toLocaleString() ?? 'Not available' }}</dd></div>
<div><dt>Type</dt><dd>{{ source.type || 'Not specified' }}</dd></div>
</dl>
Comment on lines +9 to +13
</button>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import AccessStatusBadge from './AccessStatusBadge.vue';
import type { DataSource } from './types';

const props = defineProps<{ source: DataSource; showAccessStatus?: boolean }>();
defineEmits<{ select: [id: string] }>();

const visibilityLabel = computed(() => props.source.datasetDetail.showRequestAccess ? 'Available by request' : 'Restricted');
</script>
31 changes: 31 additions & 0 deletions plugins/atlas/src/data-sources/DataSourceDetailHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<template>
<header class="data-source-detail-header">
<div>
<p class="eyebrow">Data source</p>
<h1>{{ source.datasetDetail.name }}</h1>
<p v-if="source.datasetDetail.summary || source.datasetDetail.description">
{{ source.datasetDetail.summary || source.datasetDetail.description }}
</p>
</div>
<div class="data-source-detail-header__access">
<AccessStatusBadge v-if="source.accessState" :state="source.accessState" />
<button
v-if="source.accessState === 'no_access'"
class="request-access-button"
type="button"
:disabled="requesting"
@click="$emit('request-access')"
>
{{ requesting ? 'Requesting access…' : 'Request access' }}
</button>
</div>
</header>
</template>

<script setup lang="ts">
import AccessStatusBadge from './AccessStatusBadge.vue';
import type { DataSource } from './types';

defineProps<{ source: DataSource; requesting: boolean }>();
defineEmits<{ 'request-access': [] }>();
</script>
62 changes: 62 additions & 0 deletions plugins/atlas/src/data-sources/DataSourceDetailPage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<template>
<main class="data-source-detail-page">
<button class="back-to-data-sources" type="button" @click="$emit('back')">← Back to data sources</button>

<section v-if="sources.loading && !sources.selectedDataSource" class="data-sources-state" aria-busy="true">
<span class="skeleton skeleton--title" />
<span class="skeleton skeleton--text" />
</section>

<section v-else-if="sources.error" class="data-sources-state data-sources-state--error" role="alert">
<h1>Unable to load this data source</h1>
<p>{{ sources.error }}</p>
<button type="button" @click="sources.selectDataSource(sourceId)">Try again</button>
</section>

<template v-else-if="source">
<DataSourceDetailHeader
:source="source"
:requesting="sources.requestingIds.has(source.id)"
@request-access="sources.requestAccess(source)"
/>

<section class="data-source-detail-content" aria-label="Data source details">
<div class="data-source-detail-content__description">
<h2>About this data source</h2>
<p>{{ source.datasetDetail.description || source.datasetDetail.summary || 'No description available.' }}</p>
</div>
<dl class="data-source-metadata">
<div><dt>Data source name</dt><dd>{{ source.datasetDetail.name }}</dd></div>
<div><dt>Type</dt><dd>{{ source.type || 'Not specified' }}</dd></div>
<div><dt>Data model</dt><dd>{{ source.dataModel || 'Not specified' }}</dd></div>
<div><dt>Schema</dt><dd>{{ source.tokenDatasetCode || 'Not specified' }}</dd></div>
<div><dt>Subjects</dt><dd>{{ source.totalSubjects?.toLocaleString() ?? 'Not available' }}</dd></div>
</dl>
</section>
</template>
</main>
</template>

<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import DataSourceDetailHeader from './DataSourceDetailHeader.vue';
import type { useDataSources } from './use-data-sources';

const props = defineProps<{
sourceId: string;
sources: ReturnType<typeof useDataSources>;
}>();

defineEmits<{ back: [] }>();

const source = computed(() => props.sources.selectedDataSource?.id === props.sourceId
? props.sources.selectedDataSource
: props.sources.dataSources.find((item) => item.id === props.sourceId));

function loadSource() {
void props.sources.selectDataSource(props.sourceId);
}

onMounted(loadSource);
watch(() => props.sourceId, loadSource);
</script>
72 changes: 72 additions & 0 deletions plugins/atlas/src/data-sources/DataSourceListPage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<template>
<main class="data-sources-page">
<header class="data-sources-page__header">
Comment on lines +2 to +3
<div>
<p class="data-sources-page__eyebrow">Explore</p>
<h1>Data Sources</h1>
<p>Browse the available data sources and request access where needed.</p>
</div>
<div class="data-sources-page__controls">
<label class="data-sources-search">
<span class="sr-only">Search data sources</span>
<input v-model="sources.query" type="search" placeholder="Search data sources" />
</label>
<label v-if="isAuthenticated" class="data-sources-sort">
<span>Sort by</span>
<select v-model="sources.sort">
<option value="access">Access</option>
<option value="name-asc">Name A–Z</option>
<option value="name-desc">Name Z–A</option>
</select>
Comment on lines +14 to +20
</label>
</div>
</header>

<section v-if="sources.loading" class="data-source-grid" aria-label="Loading data sources" aria-busy="true">
<div v-for="index in 4" :key="index" class="data-source-card data-source-card--skeleton">
<span class="skeleton skeleton--badge" />
<span class="skeleton skeleton--title" />
<span class="skeleton skeleton--text" />
<span class="skeleton skeleton--text skeleton--short" />
</div>
</section>

<section v-else-if="sources.error" class="data-sources-state data-sources-state--error" role="alert">
<h2>Unable to load data sources</h2>
<p>{{ sources.error }}</p>
<button type="button" @click="sources.loadDataSources">Try again</button>
</section>

<section v-else-if="sources.sortedDataSources.length === 0" class="data-sources-state">
<h2>No data sources found</h2>
<p>Try changing the search term.</p>
</section>

<section v-else class="data-source-grid" aria-label="Data sources">
<DataSourceCard
v-for="source in sources.sortedDataSources"
:key="source.id"
:source="source"
:show-access-status="isAuthenticated"
@select="$emit('select', $event)"
/>
</section>
</main>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import DataSourceCard from './DataSourceCard.vue';
import type { useDataSources } from './use-data-sources';

const props = defineProps<{
sources: ReturnType<typeof useDataSources>;
isAuthenticated: boolean;
}>();

defineEmits<{ select: [id: string] }>();

onMounted(() => {
if (!props.sources.dataSources.length) void props.sources.loadDataSources();
});
</script>
52 changes: 52 additions & 0 deletions plugins/atlas/src/data-sources/data-source-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { DataSource, DataSourceAccessRequest } from './types';

const SYSTEM_PORTAL_URL = '/d2e/system-portal';
const USER_MANAGEMENT_URL = '/d2e/usermgmt/api';

function authHeaders(token: string): HeadersInit {
return token ? { Authorization: `Bearer ${token}` } : {};
}

async function request<T>(url: string, token: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
...authHeaders(token),
...init.headers,
},
});

if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}

return response.json() as Promise<T>;
}

export function getDataSources(token: string, searchText?: string): Promise<DataSource[]> {
const search = new URLSearchParams();
if (searchText) search.set('searchText', searchText);
const query = search.toString();
return request<DataSource[]>(
`${SYSTEM_PORTAL_URL}/dataset/list${query ? `?${query}` : ''}`,
Comment on lines +30 to +31
token,
);
}
Comment on lines +26 to +34

export function getDataSource(token: string, id: string): Promise<DataSource> {
return request<DataSource>(
`${SYSTEM_PORTAL_URL}/dataset?datasetId=${encodeURIComponent(id)}`,
token,
);
}

export function createAccessRequest(
token: string,
studyId: string,
): Promise<DataSourceAccessRequest[]> {
return request<DataSourceAccessRequest[]>(`${USER_MANAGEMENT_URL}/study/access-request`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ studyId, role: 'RESEARCHER' }),
});
}
Loading
Loading