Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
84d2812
Update content integration guide link in welcome_new_user_email template
rtibbles May 15, 2026
e3bbe30
Merge pull request #5911 from learningequality/rtibbles-patch-1
rtibbles May 15, 2026
94bb656
fix(security): replace str(e) and user-input echoes in HTTP responses…
rtibblesbot May 26, 2026
5903d1b
ci: bump pnpm/action-setup to v6.0.8
rtibbles May 26, 2026
e08a3da
ci: bump pnpm/action-setup to v6.0.8 (#5941)
rtibbles May 27, 2026
1ddc9b6
close dropdowns on mousedown instead of click
akolson May 20, 2026
b2cc3ab
scope click-outside check to each dropdown's own container
akolson May 26, 2026
d33cff7
add comment to handleClickOutside in useDropdowns
akolson May 26, 2026
63c8823
fix(StudioPage): move bottom spacing inside scroll container to preve…
akolson May 18, 2026
67dce18
fix(exercises): autofocus RTE when answer/hint editor opens
akolson May 18, 2026
10b230d
fix(rte): guard editor.value in nextTick with optional chaining
akolson May 18, 2026
ab79db0
test(exercises): assert autofocus prop on open answer/hint editor
akolson May 18, 2026
526b18e
refactor(tests): use component reference instead of name string in fi…
akolson May 18, 2026
1735884
Merge pull request #5926 from akolson/auto-close-rte-menus-on-click
akolson May 27, 2026
e0102a7
feat(admin): filter and export admin users for Kolibri-usage signals
rtibbles May 19, 2026
30559f2
Merge pull request #5920 from akolson/remove-double-scrolls
rtibbles May 27, 2026
d6fcaf5
Merge pull request #5921 from akolson/new-rte-autofocus
rtibbles May 27, 2026
6cba2b2
update how linebreaks are saved so they render properly in perseus
marcellamaki May 13, 2026
350fcf1
Merge pull request #5904 from marcellamaki/fix-linebreaks
rtibbles May 27, 2026
30a0bf1
Merge pull request #5922 from rtibbles/get_laura_the_deets_stat
marcellamaki May 27, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/deploytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Use pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.8
- name: Use Node.js
uses: actions/setup-node@v6
with:
Expand Down Expand Up @@ -59,7 +59,7 @@ jobs:
# Use uv to install dependencies directly from requirements files
uv pip sync requirements.txt
- name: Use pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.8
- name: Use Node.js
uses: actions/setup-node@v6
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/frontendtest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Use pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.8
- name: Use Node.js
uses: actions/setup-node@v6
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/i18n-download.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
run: uv pip sync requirements.txt

- name: Use pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.8

- name: Use Node.js
uses: actions/setup-node@v6
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/i18n-upload.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
run: uv pip sync requirements.txt

- name: Use pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.8

- name: Use Node.js
uses: actions/setup-node@v6
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
python-version: '3.10'
ignore-nothing-to-cache: 'true'
- name: Use pnpm
uses: pnpm/action-setup@v6.0.3
uses: pnpm/action-setup@v6.0.8
- name: Use Node.js
uses: actions/setup-node@v6
with:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
:text="`Email ${$formatNumber(count)} ${count === 1 ? 'user' : 'users'}`"
@click="showMassEmailDialog = true"
/>
<IconButton
icon="download"
class="ma-0"
:color="$themeTokens.primary"
text="Download CSV"
data-test="csv"
:disabled="!count"
@click="onDownloadCSV"
/>
</h1>
<EmailUsersDialog
v-model="showMassEmailDialog"
Expand Down Expand Up @@ -72,6 +81,65 @@
/>
</VFlex>
</VLayout>
<VLayout
wrap
class="mb-2"
>
<VFlex
xs12
sm6
md3
class="px-3"
>
<VSelect
v-model="joinedWithinFilter"
:items="joinedWithinOptions"
item-text="label"
item-value="value"
label="Joined within"
box
:menu-props="{ offsetY: true }"
/>
</VFlex>
<VFlex
xs12
sm6
md3
class="px-3"
>
<VSelect
v-model="activeWithinFilter"
:items="activeWithinOptions"
item-text="label"
item-value="value"
label="Active within"
box
:menu-props="{ offsetY: true }"
/>
</VFlex>
<VFlex
xs12
sm6
md3
class="align-center d-flex px-3"
>
<Checkbox
v-model="hasPublishedFilter"
label="Has published a channel"
/>
</VFlex>
<VFlex
xs12
sm6
md3
class="align-center d-flex px-3"
>
<Checkbox
v-model="hasEditsFilter"
label="Has Studio edits"
/>
</VFlex>
</VLayout>
<VDataTable
v-model="selected"
:headers="headers"
Expand Down Expand Up @@ -141,10 +209,12 @@
import { ref, onMounted, computed, getCurrentInstance } from 'vue';
import { mapGetters } from 'vuex';
import transform from 'lodash/transform';
import { saveAs } from 'file-saver';
import { useTable } from '../../composables/useTable';
import { RouteNames, rowsPerPageItems } from '../../constants';
import EmailUsersDialog from './EmailUsersDialog';
import UserItem from './UserItem';
import client from 'shared/client';
import { useFilter } from 'shared/composables/useFilter';
import { useKeywordSearch } from 'shared/composables/useKeywordSearch';
import { routerMixin } from 'shared/mixins';
Expand All @@ -160,6 +230,64 @@
sushichef: { label: 'Sushi chef', params: { chef: true } },
};

const DATE_WINDOWS = [
{ key: 'any', label: 'Any time', months: null },
{ key: '1mo', label: 'Last month', months: 1 },
{ key: '3mo', label: 'Last 3 months', months: 3 },
{ key: '6mo', label: 'Last 6 months', months: 6 },
{ key: '1yr', label: 'Last year', months: 12 },
];

function buildDateWindowFilterMap(paramName) {
const map = {};
for (const window of DATE_WINDOWS) {
if (window.months === null) {
map[window.key] = { label: window.label, params: {} };
} else {
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - window.months);
const iso = cutoff.toISOString().slice(0, 10);
map[window.key] = { label: window.label, params: { [paramName]: iso } };
}
}
return map;
}

function useDateWindowFilter({ name, paramName }) {
const { filter, options, fetchQueryParams } = useFilter({
name,
filterMap: buildDateWindowFilterMap(paramName),
defaultValue: 'any',
});
const wrapped = computed({
get: () => filter.value.value || 'any',
set: value => {
filter.value = options.value.find(o => o.value === value) || {};
},
});
return { filter: wrapped, options, fetchQueryParams };
}

function useBooleanFilter({ name, label, paramName }) {
const filterMap = {
no: { label: 'Any', params: {} },
yes: { label, params: { [paramName]: true } },
};
const { filter, options, fetchQueryParams } = useFilter({
name,
filterMap,
defaultValue: 'no',
});
const wrapped = computed({
get: () => filter.value.value === 'yes',
set: value => {
const targetKey = value ? 'yes' : 'no';
filter.value = options.value.find(o => o.value === targetKey) || {};
},
});
return { filter: wrapped, fetchQueryParams };
}

export default {
name: 'UserTable',
components: {
Expand Down Expand Up @@ -218,6 +346,32 @@
},
});

const {
filter: joinedWithinFilter,
options: joinedWithinOptions,
fetchQueryParams: joinedWithinFetchQueryParams,
} = useDateWindowFilter({ name: 'joinedWithin', paramName: 'joined_since' });

const {
filter: activeWithinFilter,
options: activeWithinOptions,
fetchQueryParams: activeWithinFetchQueryParams,
} = useDateWindowFilter({ name: 'activeWithin', paramName: 'active_since' });

const { filter: hasPublishedFilter, fetchQueryParams: hasPublishedFetchQueryParams } =
useBooleanFilter({
name: 'hasPublished',
label: 'Has published a channel',
paramName: 'published_channel',
});

const { filter: hasEditsFilter, fetchQueryParams: hasEditsFetchQueryParams } =
useBooleanFilter({
name: 'hasEdits',
label: 'Has Studio edits',
paramName: 'has_edits',
});

onMounted(() => {
// The locationFilterMap is built from the options in the CountryField component,
// so we need to wait until it's mounted to access them.
Expand All @@ -240,6 +394,10 @@
...userTypeFetchQueryParams.value,
...locationFetchQueryParams.value,
...keywordSearchFetchQueryParams.value,
...joinedWithinFetchQueryParams.value,
...activeWithinFetchQueryParams.value,
...hasPublishedFetchQueryParams.value,
...hasEditsFetchQueryParams.value,
};
});

Expand All @@ -260,6 +418,12 @@
keywordInput,
setKeywords,
clearSearch,
joinedWithinFilter,
joinedWithinOptions,
activeWithinFilter,
activeWithinOptions,
hasPublishedFilter,
hasEditsFilter,
pagination,
loading,
loadItems,
Expand Down Expand Up @@ -333,6 +497,29 @@
mounted() {
this.updateTabTitle('Users - Administration');
},
methods: {
async onDownloadCSV() {
this.$store.dispatch('showSnackbarSimple', 'Generating CSV...');
try {
const response = await client.get(window.Urls.admin_users_download_csv(), {
params: this.filterFetchQueryParams,
responseType: 'blob',
});
const filename = `studio_users_${new Date().toISOString().slice(0, 10)}.csv`;
saveAs(response.data, filename);
} catch (error) {
const status = error.response && error.response.status;
if (status === 412) {
this.$store.dispatch(
'showSnackbarSimple',
'No filters applied. Pick at least one filter and try again.',
);
} else {
this.$store.dispatch('showSnackbarSimple', 'CSV download failed. Try again.');
}
}
},
},
};

</script>
Expand Down
Loading
Loading