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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ref, onMounted } from 'vue';
import { Organization } from 'shared/data/resources';

/**
* Composable for fetching an organization's channels with their sizes.
*/
export function useOrganizationChannels(organizationId) {
const loading = ref(true);
const channels = ref([]);
const size = ref(0);
const error = ref(false);

onMounted(() => {
Organization.fetchChannels(organizationId)
.then(data => {
channels.value = data.channels;
size.value = data.size;
})
.catch(() => {
error.value = true;
})
.finally(() => {
Comment thread
rtibblesbot marked this conversation as resolved.
loading.value = false;
});
});

return {
loading,
channels,
size,
error,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const CHANNEL_PAGE_SIZE = 25;

export const OrganizationEditTabs = {
DETAILS: 'details',
CHANNELS: 'channels',
SHARING: 'sharing',
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<template>

<div class="organization-channels-tab">
<div
v-if="show('loader', loading, 500)"
class="loader"
>
<KCircularLoader />
</div>

<p
v-else-if="error"
:style="{ color: $themeTokens.error }"
>
{{ organizationStrings.loadChannelsError$() }}
</p>

<p v-else-if="!channels.length">
{{ organizationStrings.noChannels$() }}
</p>

<template v-else>
<KTable
:caption="organizationStrings.channelsTab$()"
:headers="headers"
:rows="rows"
>
<template #cell="{ content, colIndex }">
<KExternalLink
Comment thread
rtibblesbot marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Long channel names aren't truncated. In QA, a 275-char name wrapped to 8 lines at 1280px. It wrapped to about 16 lines at 400px. Channel.name allows 200 chars, which is about 6 lines. The design shows one-line rows. Put KTextTruncator :maxLines="2" in KExternalLink's default slot, as the description cell does.

v-if="colIndex === 0"
class="notranslate"
dir="auto"
:text="content.name"
:href="channelUrl(content.id)"
openInNewTab
/>
<KTextTruncator
Comment thread
rtibblesbot marked this conversation as resolved.
v-else-if="colIndex === 1"
class="notranslate"
dir="auto"
:text="content"
:maxLines="2"
/>
<span
v-else
class="size"
>
{{ content }}
</span>
</template>
</KTable>

<p
class="total-size"
data-testid="total-size"
:style="{ color: $themeTokens.annotation }"
>
{{ organizationStrings.totalSize$({ size: formatSize(size) }) }}
</p>
</template>
</div>

</template>


<script>

import { computed } from 'vue';
import useKShow from 'kolibri-design-system/lib/composables/useKShow';
import { useOrganizationChannels } from '../../composables/useOrganizationChannels';
import { organizationStrings } from 'shared/strings/organizationStrings';
import bytesForHumans from 'shared/mixins';

export default {
name: 'OrganizationChannelsTab',
setup(props) {
const { show } = useKShow();
const { loading, channels, size, error } = useOrganizationChannels(props.organizationId);

const headers = [
{
label: organizationStrings.channelNameHeader$(),
dataType: 'undefined',
columnId: 'name',
minWidth: '200px',
},
{
label: organizationStrings.channelDescriptionHeader$(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: At 480px or less, descriptions show only about three words (Paragraph / one of a…). KTable shrinks the column to about 100px. Add minWidth: '200px' to the description column. The table already scrolls at 360px, so this costs little.

dataType: 'string',
columnId: 'description',
},
{
label: organizationStrings.channelSizeHeader$(),
dataType: 'number',
columnId: 'size',
minWidth: '100px',
},
];

const rows = computed(() =>
channels.value.map(channel => [channel, channel.description, bytesForHumans(channel.size)]),
);

function channelUrl(channelId) {
return window.Urls.channel(channelId);
}

return {
show,
loading,
channels,
size,
error,
headers,
rows,
channelUrl,
formatSize: bytesForHumans,
organizationStrings,
};
},
props: {
organizationId: {
type: String,
required: true,
},
},
};

</script>


<style lang="scss" scoped>

.loader {
margin: 48px auto;
text-align: center;
}

.size {
white-space: nowrap;
}

.total-size {
margin-top: 8px;
text-align: right;
}

</style>
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
:save="update"
:isAdmin="isAdmin"
/>
<OrganizationChannelsTab
v-else-if="tab === tabIds.CHANNELS"
:organizationId="organizationId"
/>
<OrganizationSharingTab
v-else-if="!loading"
:organizationId="organizationId"
Expand All @@ -66,6 +70,7 @@

import { RouteNames, OrganizationEditTabs, OrganizationRoles } from '../../constants';
import { useOrganization } from '../../composables/useOrganization';
import OrganizationChannelsTab from './OrganizationChannelsTab.vue';
import OrganizationDetailsTab from './OrganizationDetailsTab.vue';
import OrganizationSharingTab from './OrganizationSharingTab.vue';
import { organizationStrings } from 'shared/strings/organizationStrings';
Expand All @@ -77,6 +82,7 @@
components: {
StudioImmersiveModal,
OrganizationDetailsTab,
OrganizationChannelsTab,
OrganizationSharingTab,
},
mixins: [routerMixin],
Expand Down Expand Up @@ -124,6 +130,11 @@
label: organizationStrings.detailsTab$(),
to: this.tabLink(OrganizationEditTabs.DETAILS),
},
{
id: OrganizationEditTabs.CHANNELS,
label: organizationStrings.channelsTab$(),
to: this.tabLink(OrganizationEditTabs.CHANNELS),
},
{
id: OrganizationEditTabs.SHARING,
label: organizationStrings.sharingTab$(),
Expand Down Expand Up @@ -182,10 +193,7 @@
return;
}
const orgName = this.organization ? this.organization.name : '';
const tabLabel =
this.tab === OrganizationEditTabs.SHARING
? organizationStrings.sharingTab$()
: organizationStrings.detailsTab$();
const tabLabel = (this.tabs.find(tab => tab.id === this.tab) || this.tabs[0]).label;
this.updateTabTitle(orgName ? `${tabLabel} - ${orgName}` : tabLabel);
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { render, screen, within } from '@testing-library/vue';
import { createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router';
import OrganizationChannelsTab from '../OrganizationChannelsTab.vue';
import { Organization } from 'shared/data/resources';
import { organizationStrings } from 'shared/strings/organizationStrings';

const localVue = createLocalVue();
localVue.use(VueRouter);

const ONE_MB = 1000 * 1000;

const renderTab = () =>
render(OrganizationChannelsTab, {
localVue,
router: new VueRouter(),
props: { organizationId: 'org-1' },
});

describe('OrganizationChannelsTab', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it("lists the organization's channels with their sizes and the total size", async () => {
jest.spyOn(Organization, 'fetchChannels').mockResolvedValue({
channels: [
{ id: 'channel-1', name: 'Alpha', description: 'First channel', size: 10 * ONE_MB },
{ id: 'channel-2', name: 'Beta', description: 'Second channel', size: 560 * ONE_MB },
],
size: 570 * ONE_MB,
});

renderTab();

const table = await screen.findByRole('grid', { name: organizationStrings.channelsTab$() });
const rows = within(table).getAllByRole('row');
expect(within(rows[1]).getByRole('link', { name: /Alpha/ })).toBeInTheDocument();
expect(within(rows[1]).getByText('First channel')).toBeInTheDocument();
expect(within(rows[1]).getByText('10 MB')).toBeInTheDocument();
expect(within(rows[2]).getByText('560 MB')).toBeInTheDocument();
expect(screen.getByTestId('total-size')).toHaveTextContent(
organizationStrings.totalSize$({ size: '570 MB' }),
);
expect(Organization.fetchChannels).toHaveBeenCalledWith('org-1');
});

it('opens a channel in a new tab', async () => {
const urls = window.Urls;
window.Urls = { channel: channelId => `/channels/${channelId}/` };
jest.spyOn(Organization, 'fetchChannels').mockResolvedValue({
channels: [{ id: 'channel-1', name: 'Alpha', description: '', size: 0 }],
size: 0,
});

try {
renderTab();

const link = await screen.findByRole('link', { name: /Alpha/ });
expect(link).toHaveAttribute('href', '/channels/channel-1/');
expect(link).toHaveAttribute('target', '_blank');
} finally {
window.Urls = urls;
}
});

it('shows a message when the organization has no channels', async () => {
jest.spyOn(Organization, 'fetchChannels').mockResolvedValue({ channels: [], size: 0 });

renderTab();

expect(await screen.findByText(organizationStrings.noChannels$())).toBeInTheDocument();
expect(screen.queryByRole('grid')).not.toBeInTheDocument();
});

it('shows an error instead of an empty list when the channels fail to load', async () => {
jest.spyOn(Organization, 'fetchChannels').mockRejectedValue(new Error('Not found'));

renderTab();

expect(await screen.findByText(organizationStrings.loadChannelsError$())).toBeInTheDocument();
expect(screen.queryByText(organizationStrings.noChannels$())).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ describe('OrganizationEditPage', () => {
expect(screen.queryByRole('textbox', { name: 'Organization name' })).not.toBeInTheDocument();
});

it("shows the organization's channels on the channels tab", async () => {
jest.spyOn(Organization, 'fetchChannels').mockResolvedValue({
channels: [{ id: 'channel-1', name: 'Alpha', description: '', size: 0 }],
size: 0,
});
const router = createRouter('/organization/org-1/channels');
render(OrganizationEditPage, {
localVue,
router,
store: createStore(),
props: { organizationId: 'org-1', tab: 'channels' },
});

expect(
screen.getByRole('tab', { name: organizationStrings.channelsTab$() }),
).toBeInTheDocument();
expect(await screen.findByRole('link', { name: /Alpha/ })).toBeInTheDocument();
expect(Organization.fetchChannels).toHaveBeenCalledWith('org-1');
});

it('navigates to the "last" route when the close button is clicked', async () => {
const router = createRouter('/organization/org-1/details?last=' + RouteNames.MY_ORGANIZATIONS);
render(OrganizationEditPage, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2457,6 +2457,9 @@ export const Organization = new APIResource({
update(id, data) {
return client.patch(this.modelUrl(id), data).then(response => response.data);
},
fetchChannels(id) {
return client.get(this.getUrlFunction('channels')(id)).then(response => response.data);
},
});

export const OrganizationRole = new APIResource({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,4 +275,34 @@ export const organizationStrings = createTranslator('OrganizationStrings', {
context:
'Fallback error message shown when accepting or declining an organization invitation fails',
},
channelsTab: {
message: 'Channels',
context: "Label for the organization page tab that lists the organization's channels",
},
channelNameHeader: {
message: 'Name',
context: 'Header for the channel name column in the organization channels table',
},
channelDescriptionHeader: {
message: 'Description',
context: 'Header for the channel description column in the organization channels table',
},
channelSizeHeader: {
message: 'Size',
context:
'Header for the column showing how much storage each channel uses in the organization channels table',
},
totalSize: {
message: 'Total size: {size}',
context:
"Shown below the organization channels table. 'size' is the storage used by all listed channels, e.g. '637 MB'",
},
loadChannelsError: {
message: 'Unable to load channels',
context: 'Error message shown on the organization channels tab when the channels fail to load',
},
noChannels: {
message: 'This organization has no channels yet.',
context: 'Message shown on the organization channels tab when the organization has no channels',
},
});
Loading
Loading