Merge commit 'af0cd00b2862212486d3e6c6fd3ab16e1e6f63ea' of https://github.com/GitStartHQ/strapi into fix/add-button-repeatable-components

This commit is contained in:
gitstart 2022-12-14 13:48:13 +00:00
commit f3664d53b4
37 changed files with 469 additions and 246 deletions

View File

@ -20,7 +20,7 @@ import {
fetchUserRoles,
} from './utils/api';
import checkLatestStrapiVersion from './utils/checkLatestStrapiVersion';
import { getFullName } from '../../utils';
import { getFullName, hashAdminUserEmail } from '../../utils';
const strapiVersion = packageJSON.version;
@ -31,6 +31,7 @@ const AuthenticatedApp = () => {
const userInfo = auth.getUserInfo();
const userName = get(userInfo, 'username') || getFullName(userInfo.firstname, userInfo.lastname);
const [userDisplayName, setUserDisplayName] = useState(userName);
const [userId, setUserId] = useState(null);
const { showReleaseNotification } = useConfigurations();
const [
{ data: appInfos, status },
@ -71,6 +72,15 @@ const AuthenticatedApp = () => {
}
}, [userRoles, appInfos]);
useEffect(() => {
const getUserId = async () => {
const userId = await hashAdminUserEmail(userInfo);
setUserId(userId);
};
getUserId();
}, [userInfo]);
// We don't need to wait for the release query to be fetched before rendering the plugins
// however, we need the appInfos and the permissions
const shouldShowNotDependentQueriesLoader =
@ -81,12 +91,13 @@ const AuthenticatedApp = () => {
const appInfosValue = useMemo(() => {
return {
...appInfos,
userId,
latestStrapiReleaseTag: tag_name,
setUserDisplayName,
shouldUpdateStrapi,
userDisplayName,
};
}, [appInfos, tag_name, shouldUpdateStrapi, userDisplayName]);
}, [appInfos, tag_name, shouldUpdateStrapi, userDisplayName, userId]);
if (shouldShowLoader) {
return <LoadingIndicatorPage />;

View File

@ -20,7 +20,9 @@ const strapiVersion = packageJSON.version;
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
auth: { getUserInfo: () => ({ firstname: 'kai', lastname: 'doe' }) },
auth: {
getUserInfo: () => ({ firstname: 'kai', lastname: 'doe', email: 'testemail@strapi.io' }),
},
useGuidedTour: jest.fn(() => ({
setGuidedTourVisibility: jest.fn(),
})),

View File

@ -24,18 +24,13 @@ import { RELATION_ITEM_HEIGHT } from './constants';
import { usePrev } from '../../hooks';
const LinkEllipsis = styled(Link)`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inherit;
`;
display: block;
const BoxEllipsis = styled(Box)`
> span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inherit;
display: block;
}
`;
@ -322,7 +317,7 @@ const RelationInput = ({
}
style={style}
>
<BoxEllipsis minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
<Box minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
<Tooltip description={mainField ?? `${id}`}>
{href ? (
<LinkEllipsis to={href} disabled={disabled}>
@ -334,7 +329,7 @@ const RelationInput = ({
</Typography>
)}
</Tooltip>
</BoxEllipsis>
</Box>
{publicationState && (
<Status variant={statusColor} showBullet={false} size="S">

View File

@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Content-Manager || RelationInput should render and match snapshot 1`] = `
.c36 {
.c35 {
border: 0;
-webkit-clip: rect(0 0 0 0);
clip: rect(0 0 0 0);
@ -49,7 +49,7 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
min-width: 0px;
}
.c23 {
.c22 {
background: #eaf5ff;
padding-top: 4px;
padding-right: 8px;
@ -60,16 +60,16 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
border: 1px solid #b8e1ff;
}
.c26 {
.c25 {
padding-left: 16px;
}
.c28 {
.c27 {
color: #666687;
width: 12px;
}
.c31 {
.c30 {
background: #eafbe7;
padding-top: 4px;
padding-right: 8px;
@ -80,7 +80,7 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
border: 1px solid #c6f0c2;
}
.c34 {
.c33 {
padding-top: 8px;
}
@ -165,20 +165,20 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
color: #4945ff;
}
.c22 {
.c21 {
font-size: 0.875rem;
line-height: 1.43;
color: #32324d;
}
.c25 {
.c24 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 600;
color: #006096;
}
.c30 {
.c29 {
font-size: 0.875rem;
line-height: 1.43;
display: block;
@ -188,14 +188,14 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
color: #4945ff;
}
.c33 {
.c32 {
font-size: 0.875rem;
line-height: 1.43;
font-weight: 600;
color: #2f6846;
}
.c35 {
.c34 {
font-size: 0.75rem;
line-height: 1.33;
color: #666687;
@ -210,7 +210,7 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
margin-top: 4px;
}
.c29 path {
.c28 path {
fill: #666687;
}
@ -300,15 +300,15 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
width: 0.5625rem;
}
.c24 .c5 {
.c23 .c5 {
color: #0c75af;
}
.c32 .c5 {
.c31 .c5 {
color: #328048;
}
.c20 {
.c19 {
display: -webkit-inline-box;
display: -webkit-inline-flex;
display: -ms-inline-flexbox;
@ -324,31 +324,31 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
outline: none;
}
.c20 svg path {
.c19 svg path {
-webkit-transition: fill 150ms ease-out;
transition: fill 150ms ease-out;
fill: currentColor;
}
.c20 svg {
.c19 svg {
font-size: 0.625rem;
}
.c20 .c5 {
.c19 .c5 {
-webkit-transition: color 150ms ease-out;
transition: color 150ms ease-out;
color: currentColor;
}
.c20:hover {
.c19:hover {
color: #7b79ff;
}
.c20:active {
.c19:active {
color: #271fe0;
}
.c20:after {
.c19:after {
-webkit-transition-property: all;
transition-property: all;
-webkit-transition-duration: 0.2s;
@ -363,11 +363,11 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
border: 2px solid transparent;
}
.c20:focus-visible {
.c19:focus-visible {
outline: none;
}
.c20:focus-visible:after {
.c19:focus-visible:after {
border-radius: 8px;
content: '';
position: absolute;
@ -407,26 +407,23 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
bottom: 0;
}
.c21 {
.c20 {
display: block;
}
.c20 > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inherit;
display: block;
}
.c19 > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inherit;
}
.c27 svg path {
.c26 svg path {
fill: #8e8ea9;
}
.c27:hover svg path,
.c27:focus svg path {
.c26:hover svg path,
.c26:focus svg path {
fill: #666687;
}
@ -585,18 +582,18 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
class="c16 c17"
>
<div
class="c18 c19"
class="c18"
>
<span>
<a
aria-current="page"
aria-describedby="tooltip-1"
class="c20 c21 active"
class="c19 c20 active"
href="/"
tabindex="0"
>
<span
class="c5 c22"
class="c5 c21"
>
Relation 1
</span>
@ -604,26 +601,26 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
</span>
</div>
<div
class="c23 c24"
class="c22 c23"
>
<span
class="c5 c25"
class="c5 c24"
>
Draft
</span>
</div>
</div>
<div
class="c26"
class="c25"
>
<button
aria-label="Remove"
class="c27"
class="c26"
data-testid="remove-relation-1"
type="button"
>
<svg
class="c28 c29"
class="c27 c28"
fill="none"
height="1em"
viewBox="0 0 24 24"
@ -650,12 +647,12 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
class="c16 c17"
>
<div
class="c18 c19"
class="c18"
>
<span>
<span
aria-describedby="tooltip-3"
class="c5 c30"
class="c5 c29"
tabindex="0"
>
Relation 2
@ -663,26 +660,26 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
</span>
</div>
<div
class="c31 c32"
class="c30 c31"
>
<span
class="c5 c33"
class="c5 c32"
>
Published
</span>
</div>
</div>
<div
class="c26"
class="c25"
>
<button
aria-label="Remove"
class="c27"
class="c26"
data-testid="remove-relation-2"
type="button"
>
<svg
class="c28 c29"
class="c27 c28"
fill="none"
height="1em"
viewBox="0 0 24 24"
@ -709,12 +706,12 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
class="c16 c17"
>
<div
class="c18 c19"
class="c18"
>
<span>
<span
aria-describedby="tooltip-5"
class="c5 c30"
class="c5 c29"
tabindex="0"
>
Relation 3
@ -723,16 +720,16 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
</div>
</div>
<div
class="c26"
class="c25"
>
<button
aria-label="Remove"
class="c27"
class="c26"
data-testid="remove-relation-3"
type="button"
>
<svg
class="c28 c29"
class="c27 c28"
fill="none"
height="1em"
viewBox="0 0 24 24"
@ -753,10 +750,10 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
</div>
</div>
<div
class="c34"
class="c33"
>
<p
class="c5 c35"
class="c5 c34"
id="1-hint"
>
this is a description
@ -764,7 +761,7 @@ exports[`Content-Manager || RelationInput should render and match snapshot 1`] =
</div>
</div>
<div
class="c36"
class="c35"
>
<p
aria-live="polite"

View File

@ -35,7 +35,10 @@ function App() {
const toggleNotification = useNotification();
const { updateProjectSettings } = useConfigurations();
const { formatMessage } = useIntl();
const [{ isLoading, hasAdmin, uuid }, setState] = useState({ isLoading: true, hasAdmin: false });
const [{ isLoading, hasAdmin, uuid, deviceId }, setState] = useState({
isLoading: true,
hasAdmin: false,
});
const appInfo = useAppInfos();
const { get } = useFetchClient();
@ -81,6 +84,7 @@ function App() {
} = await axios.get(`${strapi.backendURL}/admin/init`);
updateProjectSettings({ menuLogo: prefixFileUrlWithBackendUrl(menuLogo) });
const deviceId = await getUID();
if (uuid) {
const {
@ -93,18 +97,16 @@ function App() {
setTelemetryProperties(properties);
try {
const deviceId = await getUID();
await fetch('https://analytics.strapi.io/track', {
await fetch('https://analytics.strapi.io/api/v2/track', {
method: 'POST',
body: JSON.stringify({
// This event is anonymous
event: 'didInitializeAdministration',
uuid,
userId: '',
deviceId,
properties: {
...properties,
environment: appInfo.currentEnvironment,
},
eventPropeties: {},
userProperties: { environment: appInfo.currentEnvironment },
groupProperties: { ...properties, projectId: uuid },
}),
headers: {
'Content-Type': 'application/json',
@ -115,7 +117,7 @@ function App() {
}
}
setState({ isLoading: false, hasAdmin, uuid });
setState({ isLoading: false, hasAdmin, uuid, deviceId });
} catch (err) {
toggleNotification({
type: 'warning',
@ -134,8 +136,9 @@ function App() {
() => ({
uuid,
telemetryProperties,
deviceId,
}),
[uuid, telemetryProperties]
[uuid, telemetryProperties, deviceId]
);
if (isLoading) {

View File

@ -7,3 +7,4 @@ export { default as sortLinks } from './sortLinks';
export { default as getExistingActions } from './getExistingActions';
export { default as getRequestUrl } from './getRequestUrl';
export { default as getFullName } from './getFullName';
export { default as hashAdminUserEmail } from './uniqueAdminHash';

View File

@ -0,0 +1,49 @@
import crypto from 'crypto';
import { TextEncoder } from 'util';
import hashAdminUserEmail, { utils } from '../uniqueAdminHash';
const testHashValue = '8544bf5b5389959462912699664f03ed664a4b6d24f03b13bdbc362efc147873';
describe('Creating admin user email hash in admin', () => {
afterAll(() => {
Object.defineProperty(global.self, 'crypto', {
value: undefined,
});
Object.defineProperty(global.self, 'TextEncoder', {
value: undefined,
});
});
it('should return empty string if no payload provided', async () => {
const testHash = await hashAdminUserEmail();
expect(testHash).toBe(null);
});
it('should return hash using crypto subtle', async () => {
Object.defineProperty(global.self, 'crypto', {
value: {
subtle: {
digest: jest.fn((type, message) => crypto.createHash('sha256').update(message).digest()),
},
},
configurable: true,
});
Object.defineProperty(global.self, 'TextEncoder', {
value: TextEncoder,
configurable: true,
});
const payload = {
email: 'testemail@strapi.io',
};
const spy = jest.spyOn(utils, 'digestMessage');
const testHash = await hashAdminUserEmail(payload);
expect(spy).toHaveBeenCalled();
expect(testHash).toBe(testHashValue);
});
});

View File

@ -0,0 +1,22 @@
export const utils = {
bufferToHex(buffer) {
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
},
async digestMessage(message) {
const msgUint8 = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
return this.bufferToHex(hashBuffer);
},
};
export default async function hashAdminUserEmail(payload) {
if (!payload) {
return null;
}
try {
return await utils.digestMessage(payload.email);
} catch (error) {
return null;
}
}

View File

@ -69,6 +69,7 @@ describe('Admin Controller', () => {
'info.dependencies': {
dependency: '1.0.0',
},
uuid: 'testuuid',
environment: 'development',
}[key] || value)
),
@ -85,12 +86,14 @@ describe('Admin Controller', () => {
['autoReload', false],
['info.strapi', null],
['info.dependencies', {}],
['uuid', null],
]);
expect(result.data).toBeDefined();
expect(result.data).toStrictEqual({
currentEnvironment: 'development',
autoReload: false,
strapiVersion: '1.0.0',
projectId: 'testuuid',
dependencies: {
dependency: '1.0.0',
},

View File

@ -110,6 +110,7 @@ module.exports = {
const autoReload = strapi.config.get('autoReload', false);
const strapiVersion = strapi.config.get('info.strapi', null);
const dependencies = strapi.config.get('info.dependencies', {});
const projectId = strapi.config.get('uuid', null);
const nodeVersion = process.version;
const communityEdition = !strapi.EE;
const useYarn = await exists(path.join(process.cwd(), 'yarn.lock'));
@ -120,6 +121,7 @@ module.exports = {
autoReload,
strapiVersion,
dependencies,
projectId,
nodeVersion,
communityEdition,
useYarn,

View File

@ -19,7 +19,12 @@ describe('Metrics', () => {
await metricsService.sendDidInviteUser();
expect(send).toHaveBeenCalledWith('didInviteUser', { numberOfRoles: 3, numberOfUsers: 2 });
expect(send).toHaveBeenCalledWith('didInviteUser', {
groupProperties: {
numberOfRoles: 3,
numberOfUsers: 2,
},
});
expect(countUsers).toHaveBeenCalledWith();
expect(countRoles).toHaveBeenCalledWith();
});
@ -52,7 +57,9 @@ describe('Metrics', () => {
expect(getLanguagesInUse).toHaveBeenCalledWith();
expect(send).toHaveBeenCalledWith('didChangeInterfaceLanguage', {
languagesInUse: ['en', 'fr', 'en'],
userProperties: {
languagesInUse: ['en', 'fr', 'en'],
},
});
});
});

View File

@ -5,7 +5,9 @@ const { getService } = require('../utils');
const sendDidInviteUser = async () => {
const numberOfUsers = await getService('user').count();
const numberOfRoles = await getService('role').count();
strapi.telemetry.send('didInviteUser', { numberOfRoles, numberOfUsers });
strapi.telemetry.send('didInviteUser', {
groupProperties: { numberOfRoles, numberOfUsers },
});
};
const sendDidUpdateRolePermissions = async () => {
@ -14,7 +16,8 @@ const sendDidUpdateRolePermissions = async () => {
const sendDidChangeInterfaceLanguage = async () => {
const languagesInUse = await getService('user').getLanguagesInUse();
strapi.telemetry.send('didChangeInterfaceLanguage', { languagesInUse });
// This event is anonymous
strapi.telemetry.send('didChangeInterfaceLanguage', { userProperties: { languagesInUse } });
};
module.exports = {

View File

@ -85,6 +85,7 @@ describe('Single Types', () => {
},
user: {
id: 1,
email: 'someTestEmailString',
},
};
@ -180,7 +181,9 @@ describe('Single Types', () => {
);
expect(sendTelemetry).toHaveBeenCalledWith('didCreateFirstContentTypeEntry', {
model: modelUid,
eventProperties: {
model: modelUid,
},
});
});

View File

@ -85,7 +85,9 @@ module.exports = {
ctx.body = await permissionChecker.sanitizeOutput(entity);
if (totalEntries === 0) {
strapi.telemetry.send('didCreateFirstContentTypeEntry', { model });
strapi.telemetry.send('didCreateFirstContentTypeEntry', {
eventProperties: { model },
});
}
},

View File

@ -73,7 +73,9 @@ module.exports = {
const newEntity = await entityManager.create(sanitizedBody, model, { params: query });
ctx.body = await permissionChecker.sanitizeOutput(newEntity);
await strapi.telemetry.send('didCreateFirstContentTypeEntry', { model });
await strapi.telemetry.send('didCreateFirstContentTypeEntry', {
eventProperties: { model },
});
return;
}

View File

@ -66,14 +66,15 @@ describe('metrics', () => {
global.strapi = { telemetry: { send } };
metricsService = metricsServiceLoader({ strapi });
const [containsRelationalFields, displayedFields, displayedRelationalFields] = expectedResult;
await metricsService.sendDidConfigureListView(contentType, { layouts: { list } });
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith('didConfigureListView', {
displayedFields,
containsRelationalFields,
displayedRelationalFields,
eventProperties: {
containsRelationalFields,
displayedFields,
displayedRelationalFields,
},
});
});
});

View File

@ -13,11 +13,11 @@ module.exports = ({ strapi }) => {
).length;
const data = {
containsRelationalFields: !!displayedRelationalFields,
eventProperties: { containsRelationalFields: !!displayedRelationalFields },
};
if (data.containsRelationalFields) {
Object.assign(data, {
if (data.eventProperties.containsRelationalFields) {
Object.assign(data.eventProperties, {
displayedFields,
displayedRelationalFields,
});

View File

@ -64,15 +64,17 @@ module.exports = {
components: body.components,
});
const metricsProperties = {
kind: contentType.kind,
hasDraftAndPublish: hasDraftAndPublish(contentType.schema),
const metricsPayload = {
eventProperties: {
kind: contentType.kind,
hasDraftAndPublish: hasDraftAndPublish(contentType.schema),
},
};
if (_.isEmpty(strapi.api)) {
await strapi.telemetry.send('didCreateFirstContentType', metricsProperties);
await strapi.telemetry.send('didCreateFirstContentType', metricsPayload);
} else {
await strapi.telemetry.send('didCreateContentType', metricsProperties);
await strapi.telemetry.send('didCreateContentType', metricsPayload);
}
setImmediate(() => strapi.reload());
@ -80,7 +82,9 @@ module.exports = {
ctx.send({ data: { uid: contentType.uid } }, 201);
} catch (error) {
strapi.log.error(error);
await strapi.telemetry.send('didNotCreateContentType', { error: error.message });
await strapi.telemetry.send('didNotCreateContentType', {
eventProperties: { error: error.message },
});
ctx.send({ error: error.message }, 400);
}
},

View File

@ -198,60 +198,42 @@ const cleanOrderColumns = async ({ id, attribute, db, inverseRelIds, transaction
return;
}
// Handle databases that don't support window function ROW_NUMBER
if (!strapi.db.dialect.supportsWindowFunctions()) {
await cleanOrderColumnsForOldDatabases({ id, attribute, db, inverseRelIds, transaction: trx });
return;
}
const { joinTable } = attribute;
const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable;
const update = [];
const updateBinding = [];
const select = ['??'];
const selectBinding = ['id'];
const where = [];
const whereBinding = [];
if (hasOrderColumn(attribute) && id) {
update.push('?? = b.src_order');
updateBinding.push(orderColumnName);
select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS src_order');
selectBinding.push(joinColumn.name, orderColumnName);
where.push('?? = ?');
whereBinding.push(joinColumn.name, id);
}
if (hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) {
update.push('?? = b.inv_order');
updateBinding.push(inverseOrderColumnName);
select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS inv_order');
selectBinding.push(inverseJoinColumn.name, inverseOrderColumnName);
where.push(`?? IN (${inverseRelIds.map(() => '?').join(', ')})`);
whereBinding.push(inverseJoinColumn.name, ...inverseRelIds);
}
// raw query as knex doesn't allow updating from a subquery
// https://github.com/knex/knex/issues/2504
switch (strapi.db.dialect.client) {
case 'mysql':
await db.connection
.raw(
`UPDATE
?? as a,
(
SELECT ${select.join(', ')}
FROM ??
WHERE ${where.join(' OR ')}
) AS b
SET ${update.join(', ')}
WHERE b.id = a.id`,
[joinTable.name, ...selectBinding, joinTable.name, ...whereBinding, ...updateBinding]
)
.transacting(trx);
await cleanOrderColumnsForInnoDB({ id, attribute, db, inverseRelIds, transaction: trx });
break;
default: {
const { joinTable } = attribute;
const { joinColumn, inverseJoinColumn, orderColumnName, inverseOrderColumnName } = joinTable;
const update = [];
const updateBinding = [];
const select = ['??'];
const selectBinding = ['id'];
const where = [];
const whereBinding = [];
if (hasOrderColumn(attribute) && id) {
update.push('?? = b.src_order');
updateBinding.push(orderColumnName);
select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS src_order');
selectBinding.push(joinColumn.name, orderColumnName);
where.push('?? = ?');
whereBinding.push(joinColumn.name, id);
}
if (hasInverseOrderColumn(attribute) && !isEmpty(inverseRelIds)) {
update.push('?? = b.inv_order');
updateBinding.push(inverseOrderColumnName);
select.push('ROW_NUMBER() OVER (PARTITION BY ?? ORDER BY ??) AS inv_order');
selectBinding.push(inverseJoinColumn.name, inverseOrderColumnName);
where.push(`?? IN (${inverseRelIds.map(() => '?').join(', ')})`);
whereBinding.push(inverseJoinColumn.name, ...inverseRelIds);
}
const joinTableName = addSchema(joinTable.name);
// raw query as knex doesn't allow updating from a subquery
// https://github.com/knex/knex/issues/2504
await db.connection
.raw(
`UPDATE ?? as a
@ -265,24 +247,29 @@ const cleanOrderColumns = async ({ id, attribute, db, inverseRelIds, transaction
[joinTableName, ...updateBinding, ...selectBinding, joinTableName, ...whereBinding]
)
.transacting(trx);
/*
`UPDATE :joinTable: as a
SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order
FROM (
SELECT
id,
ROW_NUMBER() OVER ( PARTITION BY :joinColumn: ORDER BY :orderColumn:) AS src_order,
ROW_NUMBER() OVER ( PARTITION BY :inverseJoinColumn: ORDER BY :inverseOrderColumn:) AS inv_order
FROM :joinTable:
WHERE :joinColumn: = :id OR :inverseJoinColumn: IN (:inverseRelIds)
) AS b
WHERE b.id = a.id`,
*/
}
/*
`UPDATE :joinTable: as a
SET :orderColumn: = b.src_order, :inverseOrderColumn: = b.inv_order
FROM (
SELECT
id,
ROW_NUMBER() OVER ( PARTITION BY :joinColumn: ORDER BY :orderColumn:) AS src_order,
ROW_NUMBER() OVER ( PARTITION BY :inverseJoinColumn: ORDER BY :inverseOrderColumn:) AS inv_order
FROM :joinTable:
WHERE :joinColumn: = :id OR :inverseJoinColumn: IN (:inverseRelIds)
) AS b
WHERE b.id = a.id`,
*/
}
};
const cleanOrderColumnsForOldDatabases = async ({
/*
* Ensure that orders are following a 1, 2, 3 sequence, without gap.
* The use of a temporary table instead of a window function makes the query compatible with MySQL 5 and prevents some deadlocks to happen in innoDB databases
*/
const cleanOrderColumnsForInnoDB = async ({
id,
attribute,
db,
@ -319,6 +306,9 @@ const cleanOrderColumnsForOldDatabases = async ({
}
)
.transacting(trx);
// raw query as knex doesn't allow updating from a subquery
// https://github.com/knex/knex/issues/2504
await db.connection
.raw(
`UPDATE ?? as a, (SELECT * FROM ??) AS b

View File

@ -5,22 +5,31 @@ import useAppInfos from '../useAppInfos';
const useTracking = () => {
const trackRef = useRef();
const { uuid, telemetryProperties } = useContext(TrackingContext);
const { uuid, telemetryProperties, deviceId } = useContext(TrackingContext);
const appInfo = useAppInfos();
const userId = appInfo?.userId;
trackRef.current = async (event, properties) => {
if (uuid && !window.strapi.telemetryDisabled) {
try {
await axios.post('https://analytics.strapi.io/track', {
event,
properties: {
...telemetryProperties,
...properties,
projectType: window.strapi.projectType,
environment: appInfo.currentEnvironment,
await axios.post(
'https://analytics.strapi.io/api/v2/track',
{
event,
userId,
deviceId,
eventProperties: { ...properties },
userProperties: {},
groupProperties: {
...telemetryProperties,
projectId: uuid,
projectType: window.strapi.projectType,
},
},
uuid,
});
{
headers: { 'Content-Type': 'application/json' },
}
);
} catch (err) {
// Silent
}

View File

@ -25,6 +25,7 @@ function setup(props) {
telemetryProperties: {
nestedProperty: true,
},
deviceId: 'someTestDeviceId',
...props,
}}
>
@ -45,21 +46,33 @@ describe('useTracking', () => {
test('Call trackUsage() with all attributes', async () => {
useAppInfos.mockReturnValue({
currentEnvironment: 'testing',
userId: 'someTestUserId',
});
const { result } = await setup();
result.current.trackUsage('event', { trackingProperty: true });
expect(axios.post).toBeCalledWith(expect.any(String), {
event: 'event',
uuid: 1,
properties: expect.objectContaining({
environment: 'testing',
nestedProperty: true,
trackingProperty: true,
}),
});
expect(axios.post).toBeCalledWith(
expect.any(String),
{
userId: 'someTestUserId',
deviceId: 'someTestDeviceId',
event: 'event',
eventProperties: {
trackingProperty: true,
},
groupProperties: {
nestedProperty: true,
projectId: 1,
projectType: 'Community',
},
userProperties: {},
},
{
headers: { 'Content-Type': 'application/json' },
}
);
});
test('Do not track if it has been disabled', async () => {

View File

@ -242,11 +242,14 @@ class Strapi {
sendStartupTelemetry() {
// Emit started event.
// do not await to avoid slower startup
// This event is anonymous
this.telemetry.send('didStartServer', {
database: strapi.config.get('database.connection.client'),
plugins: Object.keys(strapi.plugins),
// TODO: to add back
// providers: this.config.installedProviders,
groupProperties: {
database: strapi.config.get('database.connection.client'),
plugins: Object.keys(strapi.plugins),
// TODO: to add back
// providers: this.config.installedProviders,
},
});
}

View File

@ -53,12 +53,12 @@ const generateNewPackageJSON = (packageObj) => {
const sendEvent = async (uuid) => {
try {
await fetch('https://analytics.strapi.io/track', {
await fetch('https://analytics.strapi.io/api/v2/track', {
method: 'POST',
body: JSON.stringify({
event: 'didOptInTelemetry',
uuid,
deviceId: machineID(),
groupProperties: { projectId: uuid },
}),
headers: { 'Content-Type': 'application/json' },
});

View File

@ -28,12 +28,12 @@ const writePackageJSON = async (path, file, spacing) => {
const sendEvent = async (uuid) => {
try {
await fetch('https://analytics.strapi.io/track', {
await fetch('https://analytics.strapi.io/api/v2/track', {
method: 'POST',
body: JSON.stringify({
event: 'didOptOutTelemetry',
uuid,
deviceId: machineID(),
groupProperties: { projectId: uuid },
}),
headers: { 'Content-Type': 'application/json' },
});

View File

@ -0,0 +1,41 @@
'use strict';
const crypto = require('crypto');
const { generateAdminUserHash } = require('../admin-user-hash');
const createContext = require('../../../../../../../test/helpers/create-context');
describe('user email hash', () => {
test('should create a hash from admin user email', () => {
const state = {
user: {
email: 'testemail@strapi.io',
},
};
const ctx = createContext({}, { state });
global.strapi = {
requestContext: {
get: jest.fn(() => ctx),
},
};
const hash = crypto.createHash('sha256').update('testemail@strapi.io').digest('hex');
const userId = generateAdminUserHash();
expect(userId).toBe(hash);
});
test('should return empty string if user is not available on ctx', () => {
const ctx = createContext({}, {});
global.strapi = {
requestContext: {
get: jest.fn(() => ctx),
},
};
const userId = generateAdminUserHash();
expect(userId).toBe('');
});
});

View File

@ -29,6 +29,9 @@ describe('metrics', () => {
root: process.cwd(),
},
},
requestContext: {
get: jest.fn(() => ({})),
},
});
metricsInstance.register();
@ -60,6 +63,9 @@ describe('metrics', () => {
root: process.cwd(),
},
},
requestContext: {
get: jest.fn(() => ({})),
},
});
metricsInstance.register();
@ -89,18 +95,21 @@ describe('metrics', () => {
root: process.cwd(),
},
},
requestContext: {
get: jest.fn(() => ({})),
},
});
send('someEvent');
expect(fetch).toHaveBeenCalled();
expect(fetch.mock.calls[0][0]).toBe('https://analytics.strapi.io/track');
expect(fetch.mock.calls[0][0]).toBe('https://analytics.strapi.io/api/v2/track');
expect(fetch.mock.calls[0][1].method).toBe('POST');
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
event: 'someEvent',
uuid: 'test',
properties: {
groupProperties: {
projectType: 'Community',
projectId: 'test',
},
});
@ -128,6 +137,9 @@ describe('metrics', () => {
root: process.cwd(),
},
},
requestContext: {
get: jest.fn(() => ({})),
},
});
send('someEvent');

View File

@ -0,0 +1,15 @@
'use strict';
const crypto = require('crypto');
const generateAdminUserHash = () => {
const ctx = strapi?.requestContext?.get();
if (!ctx?.state?.user) {
return '';
}
return crypto.createHash('sha256').update(ctx.state.user.email).digest('hex');
};
module.exports = {
generateAdminUserHash,
};

View File

@ -55,10 +55,12 @@ const createTelemetryInstance = (strapi) => {
return sendEvent(
'didCheckLicense',
{
licenseInfo: {
...ee.licenseInfo,
projectHash: hashProject(strapi),
dependencyHash: hashDep(strapi),
groupProperties: {
licenseInfo: {
...ee.licenseInfo,
projectHash: hashProject(strapi),
dependencyHash: hashDep(strapi),
},
},
},
{

View File

@ -19,7 +19,7 @@ const createMiddleware = ({ sendEvent }) => {
// Send max. 1000 events per day.
if (_state.counter < 1000) {
sendEvent('didReceiveRequest', { url: ctx.request.url });
sendEvent('didReceiveRequest', { eventProperties: { url: ctx.request.url } });
// Increase counter.
_state.counter += 1;

View File

@ -10,7 +10,7 @@ const { isUsingTypeScriptSync } = require('@strapi/typescript-utils');
const { env } = require('@strapi/utils');
const ee = require('../../utils/ee');
const machineID = require('../../utils/machine-id');
const stringifyDeep = require('./stringify-deep');
const { generateAdminUserHash } = require('./admin-user-hash');
const defaultQueryOpts = {
timeout: 1000,
@ -42,41 +42,49 @@ module.exports = (strapi) => {
const serverRootPath = strapi.dirs.app.root;
const adminRootPath = path.join(strapi.dirs.app.root, 'src', 'admin');
const anonymousMetadata = {
const anonymousUserProperties = {
environment: strapi.config.environment,
os: os.type(),
osPlatform: os.platform(),
osArch: os.arch(),
osRelease: os.release(),
nodeVersion: process.versions.node,
};
const anonymousGroupProperties = {
docker: process.env.DOCKER || isDocker(),
isCI: ciEnv.isCI,
version: strapi.config.get('info.strapi'),
projectType: isEE ? 'Enterprise' : 'Community',
useTypescriptOnServer: isUsingTypeScriptSync(serverRootPath),
useTypescriptOnAdmin: isUsingTypeScriptSync(adminRootPath),
projectId: uuid,
isHostedOnStrapiCloud: env('STRAPI_HOSTING', null) === 'strapi.cloud',
};
addPackageJsonStrapiMetadata(anonymousMetadata, strapi);
addPackageJsonStrapiMetadata(anonymousGroupProperties, strapi);
return async (event, payload = {}, opts = {}) => {
const userId = generateAdminUserHash();
const reqParams = {
method: 'POST',
body: JSON.stringify({
event,
uuid,
userId,
deviceId,
properties: stringifyDeep({
...payload,
...anonymousMetadata,
}),
eventProperties: payload.eventProperties,
userProperties: userId ? { ...anonymousUserProperties, ...payload.userProperties } : {},
groupProperties: {
...anonymousGroupProperties,
...payload.groupProperties,
},
}),
..._.merge({}, defaultQueryOpts, opts),
};
try {
const res = await fetch(`${ANALYTICS_URI}/track`, reqParams);
const res = await fetch(`${ANALYTICS_URI}/api/v2/track`, reqParams);
return res.ok;
} catch (err) {
return false;

View File

@ -17,7 +17,7 @@ try {
process.env.npm_config_global === 'true' ||
JSON.parse(process.env.npm_config_argv).original.includes('global')
) {
fetch('https://analytics.strapi.io/track', {
fetch('https://analytics.strapi.io/api/v2/track', {
method: 'POST',
body: JSON.stringify({
event: 'didInstallStrapi',

View File

@ -40,10 +40,12 @@ module.exports = {
if (deletedFiles.length + deletedFolders.length > 1) {
strapi.telemetry.send('didBulkDeleteMediaLibraryElements', {
rootFolderNumber: deletedFolders.length,
rootAssetNumber: deletedFiles.length,
totalFolderNumber,
totalAssetNumber: totalFileNumber + deletedFiles.length,
eventProperties: {
rootFolderNumber: deletedFolders.length,
rootAssetNumber: deletedFiles.length,
totalFolderNumber,
totalAssetNumber: totalFileNumber + deletedFiles.length,
},
});
}
@ -229,10 +231,12 @@ module.exports = {
});
strapi.telemetry.send('didBulkMoveMediaLibraryElements', {
rootFolderNumber: updatedFolders.length,
rootAssetNumber: updatedFiles.length,
totalFolderNumber,
totalAssetNumber: totalFileNumber + updatedFiles.length,
eventProperties: {
rootFolderNumber: updatedFolders.length,
rootAssetNumber: updatedFiles.length,
totalFolderNumber,
totalAssetNumber: totalFileNumber + updatedFiles.length,
},
});
ctx.body = {

View File

@ -91,7 +91,9 @@ module.exports = ({ strapi }) => ({
async sendMetrics() {
const metrics = await this.computeMetrics();
strapi.telemetry.send('didSendUploadPropertiesOnceAWeek', metrics);
strapi.telemetry.send('didSendUploadPropertiesOnceAWeek', {
groupProperties: { metrics },
});
const metricsInfoStored = await getMetricsStoreValue();
await setMetricsStoreValue({ ...metricsInfoStored, lastWeeklyUpdate: new Date().getTime() });

View File

@ -340,6 +340,7 @@ module.exports = ({ strapi }) => ({
if (user) {
fileValues[UPDATED_BY_ATTRIBUTE] = user.id;
}
sendMediaMetrics(fileValues);
const res = await strapi.entityService.update(FILE_MODEL_UID, id, { data: fileValues });
@ -355,6 +356,7 @@ module.exports = ({ strapi }) => ({
fileValues[UPDATED_BY_ATTRIBUTE] = user.id;
fileValues[CREATED_BY_ATTRIBUTE] = user.id;
}
sendMediaMetrics(fileValues);
const res = await strapi.query(FILE_MODEL_UID).create({ data: fileValues });

View File

@ -53,33 +53,46 @@ function captureStderr(name, error) {
return captureError(name);
}
const getProperties = (scope, error) => ({
error: typeof error === 'string' ? error : error && error.message,
os: os.type(),
osPlatform: os.platform(),
osArch: os.arch(),
osRelease: os.release(),
version: scope.strapiVersion,
nodeVersion: process.versions.node,
docker: scope.docker,
useYarn: scope.useYarn,
useTypescriptOnServer: scope.useTypescript,
useTypescriptOnAdmin: scope.useTypescript,
isHostedOnStrapiCloud: process.env.STRAPI_HOSTING === 'strapi.cloud',
noRun: (scope.runQuickstartApp !== true).toString(),
});
const getProperties = (scope, error) => {
const eventProperties = {
error: typeof error === 'string' ? error : error && error.message,
};
const userProperties = {
os: os.type(),
osPlatform: os.platform(),
osArch: os.arch(),
osRelease: os.release(),
nodeVersion: process.versions.node,
};
const groupProperties = {
version: scope.strapiVersion,
docker: scope.docker,
useYarn: scope.useYarn,
useTypescriptOnServer: scope.useTypescript,
useTypescriptOnAdmin: scope.useTypescript,
isHostedOnStrapiCloud: process.env.STRAPI_HOSTING === 'strapi.cloud',
noRun: (scope.runQuickstartApp !== true).toString(),
projectId: scope.uuid,
};
function trackEvent(event, body) {
return {
eventProperties,
userProperties,
groupProperties: addPackageJsonStrapiMetadata(groupProperties, scope),
};
};
function trackEvent(event, payload) {
if (process.env.NODE_ENV === 'test') {
return;
}
try {
return fetch('https://analytics.strapi.io/track', {
return fetch('https://analytics.strapi.io/api/v2/track', {
method: 'POST',
body: JSON.stringify({
event,
...body,
...payload,
}),
timeout: 1000,
headers: { 'Content-Type': 'application/json' },
@ -91,14 +104,12 @@ function trackEvent(event, body) {
}
function trackError({ scope, error }) {
const { uuid } = scope;
const properties = getProperties(scope, error);
try {
return trackEvent('didNotCreateProject', {
uuid,
deviceId: scope.deviceId,
properties: addPackageJsonStrapiMetadata(properties, scope),
...properties,
});
} catch (err) {
/** ignore errors */
@ -107,14 +118,12 @@ function trackError({ scope, error }) {
}
function trackUsage({ event, scope, error }) {
const { uuid } = scope;
const properties = getProperties(scope, error);
try {
return trackEvent(event, {
uuid,
deviceId: scope.deviceId,
properties: addPackageJsonStrapiMetadata(properties, scope),
...properties,
});
} catch (err) {
/** ignore errors */

View File

@ -44,7 +44,9 @@ describe('Metrics', () => {
await sendDidInitializeEvent();
expect(strapi.telemetry.send).toHaveBeenCalledWith('didInitializeI18n', {
numberOfContentTypes: 1,
groupProperties: {
numberOfContentTypes: 1,
},
});
});
@ -88,7 +90,9 @@ describe('Metrics', () => {
await sendDidUpdateI18nLocalesEvent();
expect(strapi.telemetry.send).toHaveBeenCalledWith('didUpdateI18nLocales', {
numberOfLocales: 3,
groupProperties: {
numberOfLocales: 3,
},
});
});
});

View File

@ -11,13 +11,15 @@ const sendDidInitializeEvent = async () => {
0
)(strapi.contentTypes);
await strapi.telemetry.send('didInitializeI18n', { numberOfContentTypes });
await strapi.telemetry.send('didInitializeI18n', { groupProperties: { numberOfContentTypes } });
};
const sendDidUpdateI18nLocalesEvent = async () => {
const numberOfLocales = await getService('locales').count();
await strapi.telemetry.send('didUpdateI18nLocales', { numberOfLocales });
await strapi.telemetry.send('didUpdateI18nLocales', {
groupProperties: { numberOfLocales },
});
};
module.exports = () => ({